// ==UserScript== // @name Userscript Code Scanner [Local + AI] // @name:en Userscript Code Scanner [Local + AI] // @name:zh 用户脚本代码扫描器 [本地 + AI] // @namespace http://tampermonkey.net/ // @version 1.7.1 // @description UserScript 脚本安全检测(本地扫描 + AI 分析) // @description:en Security check for UserScript scripts (Local scan + AI analysis) // @description:zh UserScript 脚本安全检测(本地扫描 + AI 分析) // @author niwei // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM.xmlHttpRequest // @grant GM.getValue // @grant GM.setValue // @grant GM_registerMenuCommand // @grant GM.registerMenuCommand // @grant unsafeWindow // @connect api.groq.com // @connect api.deepseek.com // @connect api.openai.com // @connect api.x.ai // @connect api.anthropic.com // @connect generativelanguage.googleapis.com // @connect openrouter.ai // @connect js.puter.com // @connect greasyfork.org // @connect update.greasyfork.org // @connect cdn.jsdelivr.net // @connect cdn.bootcdn.net // @connect cdn.staticfile.org // @connect cdnjs.cloudflare.com // @connect update.greasyfork.org // @connect scriptcat.org // @connect openuserjs.org // @connect github.com // @connect raw.githubusercontent.com // @connect localhost // @connect 127.0.0.1 // @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== // ╔═══════════════════════════════════════════════════════════════════════╗ // ║ 接入「自定义 API」说明 ║ // ╠═══════════════════════════════════════════════════════════════════════╣ // ║ 1. 在脚本头部的 @connect 中加入你自有 API 的域名,例如: ║ // ║ // @connect your-api.example.com ║ // ║ 本地 API 已默认支持:localhost / 127.0.0.1 ║ // ║ 2. 在页面小组件的「AI 模型」下拉框中,选择「🔌 自定义 API」 ║ // ║ 3. 填写 API 地址(OpenAI 兼容的 /chat/completions)、模型名称 ║ // ║ 「Token」输入框可随时填写/更新密钥,清空则请求时不携带 ║ // ║ 4. 如你的 API 需要密钥,填入 Token 后会自动保存并用于后续请求 ║ // ╚═══════════════════════════════════════════════════════════════════════╝ // ╔═══════════════════════════════════════════════════════════════════════════╗ // ║ 用户设置 —— 只需编辑本区块 ║ // ╠═══════════════════════════════════════════════════════════════════════════╣ // 强制清除所有已保存的 API 密钥。 // 设为 true → 刷新页面 → 再改回 false。 const CLEAR_ALL_API_KEYS = false; // 诊断:在控制台 (F12) 输出各供应商可用的模型列表。 // 需要已保存的密钥。设为 true → 打开控制台 → 再改回 false。 const TEST_MODELS_AVAILABLE = false; // ╚═══════════════════════════════════════════════════════════════════════╝ // ╔═══════════════════════════════════════════════════════════════════════════╗ // ║ 变更历史 ║ // ║ v1.7.1 新增 Anthropic (Claude) 支持 ║ // ║ • 新增 Claude 模型:走 Anthropic Messages API(x-api-key 鉴权) ║ // ║ • 响应解析适配 content[0].text,提示词与 OpenAI 分支保持一致 ║ // ║ v1.7.0 评级放宽 + AI 报告放大查看 ║ // ║ • computeRisk 改纯累积分数分档(safe<8≤low<16≤med<26≤danger): ║ // ║ 单条常见 API(GM XHR/atob/本地存储等)不再直达中/高危,防大量误报 ║ // ║ • 底线保底:v4 单条(eval/挖矿)至少低危;「采集+外发」「混淆+动态执行」║ // ║ 组合至少中危,放宽的同时守住检测能力 ║ // ║ • AI 结果区新增 ⛶ 放大按钮:全屏覆盖层查看报告,Esc/⤡ 还原 ║ // ║ v1.6.3 AI 权限使用全面披露 ║ // ║ • 提示词新增 PERMISSION USAGE:对比 @grant 声明与明文实际调用 ║ // ║ • 披露仅在混淆/编码载荷中出现的权限使用,附解码负载供 AI 判定 ║ // ║ v1.6.2 新增「手动输入脚本」菜单 ║ // ║ • 在脚本菜单注册「分析脚本」入口,可手动粘贴任意脚本源码并分析 ║ // ║ • 仅通过油猴菜单触达,页面无脚本链接时完全隐藏 ║ // ║ v1.6.1 • 最小化圆球居中图标 ║ // ║ • 所有模型类型都显示 Token 输入框 ║ // ║ • 多个安装链接以悬浮/长按为准 ║ // ║ v1.6.0 增强恶意脚本筛选功能 ║ // ║ • 通过WebRTC泄漏、键盘记录、屏幕截图等新模式扩展了检测规则。 ║ // ║ • 实施多信号加权评分,以提高风险评估的准确性。 // ║ • 为模糊的有效载荷(包括base64和十六进制)添加了深度解码,以揭示隐藏的威胁。║ // ║ • 更新了“scanCode”功能,以支持解码内容的二次扫描。 ║ // ║ • 引入了“computeRisk”函数,用累积评分系统取代了直接的最大严重性检查。 ║ // ║ • 增强的AI提示摘要,包括风险评分和解码的有效载荷指标。 ║ // ║ v1.5.0 新增自定义 API分支: ║ // ║ v1.4.0 重构为多悬浮窗 UI: ║ // ║ • 按功能模块拆分为 3 个独立可拖拽悬浮窗: ║ // ║ 状态摘要窗(右下角,自动显示) ║ // ║ 威胁详情窗(元数据/域名/威胁卡片,默认隐藏) ║ // ║ AI 分析窗(模型/语言/自定义 API/AI 回复,默认隐藏) ║ // ║ • 新增 createPanel() 组件:可拖拽、折叠、最小化、关闭,位置持久化 ║ // ║ • 全局入口按钮(FAB):所有窗口关闭后可点击一键恢复 ║ // ║ • 现代化视觉:毛玻璃背景、圆角、阴影、动画过渡、风险色动态指示 ║ // ║ v1.3.0 支持任意脚本下载站(扩展自 GreasyFork 专用): ║ // ║ • @match 放宽为 *://*/*,可预取任意站的 .user.js ║ // ║ • 页面加载时自动查找 .user.js 下载链接,GM_xmlhttpRequest 预取源码 ║ // ║ • 新增 parseMetadata() 解析 ==UserScript== 头部注释块 @key value ║ // ║ • UI 新增脚本元数据卡片(name/version/description 高亮) ║ // ║ • 扫描完成后给 .user.js 下载按钮描风险色 + 悬停提示(安装器介入前) ║ // ║ • scan() 改为 async,MutationObserver + watchForContent 适配异步流程 ║ // ║ •
 代码块读取降为后备,兼容 GreasyFork code 页等无下载链接场景  ║
// ║          • 新增自定义 API分支:OpenAI 兼容端点,地址/模型名持久化       ║
// ║          • 自定义 API 可配置无密钥调用(Authorization header 条件添加)       ║
// ║          • 新增三语 i18n 键 customCfg / customUrl / customModel             ║
// ║  v1.2.1  增加了中文并将注释翻译为中文                                        ║
// ╠═══════════════════════════════════════════════════════════════════════════╣
// ║  本脚本基于 GreasyFork 上的原脚本 "GF Code Scanner"          ║
// ║    (https://greasyfork.org/en/scripts/570775-gf-code-scanner-local-ai )  ║
// ║  修改而来。原作者:ktt21                                                    ║
// ╠═══════════════════════════════════════════════════════════════════════════╣
// ║  以下为原版更新日志                                                         ║
// ║  v1.2.0  新增「描述该脚本」复选框:                                          ║
// ║          • 启用时,AI 会额外请求对该脚本及其工作算法的简要描述                ║
// ║          • 基于本地检测到的参数,从用户数据隐私角度给出关于该脚本             ║
// ║            安全性的一则幽默结论                                              ║
// ║          • 复选框状态不会在页面之间保存                                     ║
// ║  v1.1.0  性能优化(基于 Nemotron 分析):                                    ║
// ║          • init() 中改用 MutationObserver 替代 setInterval,移除           ║
// ║            定时器,减少 CPU 唤醒                                             ║
// ║          • 将正则预编译到 RULES[i].re,new RegExp 只执行 1 次                ║
// ║          • 每次调用 extractUrls 时都重置 URL_RE.lastIndex=0                 ║
// ║          • aiInProgress 标志,防止并行 XHR                                  ║
// ║          • 启动时只判定一次 GM 类型 (hasNewGM)                              ║
// ║          • buildWidget 中用 parts[]+join 替代 h+=                           ║
// ║          • 用 requestIdleCallback 触发扫描(降级为 setTimeout)             ║
// ║          • Turbolinks 时调用 cleanup(),显式解绑 observer                   ║
// ║          • 具名常量:RETRY_MS, MAX_RETRIES, MAX_CODE_LEN                    ║
// ║  v1.0.8  Puter.js 的 Firefox 修复:cloneInto();密钥提示;                  ║
// ║          nVidia→OpenRouter;禁用 BrowserAI;注释改为 RU                     ║
// ║  v1.0.7  CLEAR_ALL_API_KEYS 标志;Qwen 经 Groq;Puter.js;OpenRouter        ║
// ║  v1.0.6  @compatible 精确版本;AI 回复的隔离 CSS                            ║
// ║  v1.0.5  修复 Firefox 中 window.prompt() 的问题 (TDZ)                       ║
// ║  v1.0.4  通用 GM API;回复语言选择;i18n 界面                               ║
// ║  v1.0.3  首次公开发布                                                       ║
// ╚═══════════════════════════════════════════════════════════════════════════╝


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

    // ══════════════════════════════════════════════════════════════════════════
    //  具名常量(替代代码中的「魔法数字」)
    // ══════════════════════════════════════════════════════════════════════════════════════
    const MAX_CODE_LEN  = 15000;  // 发送给 AI 的代码提示字符数上限
    const MAX_FIND_LINES = 5;     // 每条威胁最多显示多少行代码
    const MAX_RETRIES= 8;         // 等待脚本内容出现的最多重试次数
    const RETRY_MS   = 800;       // 每次重试的间隔毫秒
    const MAX_DECODE = 20000;     // 混淆解码还原输出的字符数上限(防膨胀)

    // ══════════════════════════════════════════════════════════════════════════
    //  AI 回复(markdown)的显示样式
    //  只注入一次。所有规则都隔离在 #gfr 上。
    // ══════════════════════════════════════════════════════════════════════════
    if (!document.getElementById('gfs-md-style')) {
        const style = document.createElement('style');
        style.id = 'gfs-md-style';
        style.textContent = `
/* ═══════════════════════════════════════════════════════════════
   主题变量(默认:深色)
   ═══════════════════════════════════════════════════════════════ */
:root {
    --gfs-bg: rgba(26, 27, 46, 0.95);
    --gfs-header-bg: rgba(0, 0, 0, 0.2);
    --gfs-panel-border: rgba(255, 255, 255, 0.1);
    --gfs-header-border: rgba(255, 255, 255, 0.05);
    --gfs-text: #e0e0e0;
    --gfs-title: #ffffff;
    --gfs-muted: #888888;
    --gfs-subtle: #9e9e9e;
    --gfs-hint: #8b949e;
    --gfs-ghost-bg: rgba(255, 255, 255, 0.05);
    --gfs-ghost-hover: rgba(255, 255, 255, 0.1);
    --gfs-ghost-color: #aaaaaa;
    --gfs-ghost-hover-color: #ffffff;
    --gfs-card-bg: rgba(22, 33, 62, 0.6);
    --gfs-card-title: #4fc3f7;
    --gfs-card-content: #cccccc;
    --gfs-input-bg: rgba(42, 42, 62, 0.8);
    --gfs-input-border: rgba(255, 255, 255, 0.1);
    --gfs-input-color: #ffffff;
    --gfs-check: #aaaaaa;
    --gfs-label: #888888;
    --gfs-desc: #cccccc;
    --gfs-warn-bg: #2a1e00;
    --gfs-warn-text: #ffd700;
    --gfs-scroll-track: rgba(0, 0, 0, 0.2);
    --gfs-scroll-thumb: rgba(255, 255, 255, 0.2);
    --gfs-scroll-thumb-hover: rgba(255, 255, 255, 0.3);
    --gfs-code-bg: #0d1117;
    --gfs-code-text: #888888;
    --gfs-scriptname: #cccccc;
    --gfs-versub: #888888;
    /* AI 回复(markdown) */
    --gfs-md-bg: #0d1117;
    --gfs-md-text: #c9d1d9;
    --gfs-md-code-bg: rgba(110, 118, 129, 0.2);
    --gfs-md-code-text: #c9d1d9;
    --gfs-md-pre-bg: rgba(0, 0, 0, 0.4);
    --gfs-md-heading: #79b8ff;
    --gfs-md-border: #30363d;
    --gfs-md-strong: #f0f0f0;
    --gfs-md-em: #d0d0d0;
    --gfs-md-link: #58a6ff;
    --gfs-md-block: #8b949e;
}

/* 亮色主题:跟随系统(system)且非手动强制深色时生效 */
@media (prefers-color-scheme: light) {
    :root:not([data-gfs-theme="dark"]) {
        --gfs-bg: rgba(255, 255, 255, 0.96);
        --gfs-header-bg: rgba(0, 0, 0, 0.04);
        --gfs-panel-border: rgba(0, 0, 0, 0.12);
        --gfs-header-border: rgba(0, 0, 0, 0.06);
        --gfs-text: #1e1e2e;
        --gfs-title: #111111;
        --gfs-muted: #555555;
        --gfs-subtle: #6b6b6b;
        --gfs-hint: #6b7280;
        --gfs-ghost-bg: rgba(0, 0, 0, 0.05);
        --gfs-ghost-hover: rgba(0, 0, 0, 0.1);
        --gfs-ghost-color: #555555;
        --gfs-ghost-hover-color: #111111;
        --gfs-card-bg: rgba(240, 244, 250, 0.85);
        --gfs-card-title: #2563eb;
        --gfs-card-content: #333333;
        --gfs-input-bg: rgba(240, 240, 245, 0.9);
        --gfs-input-border: rgba(0, 0, 0, 0.15);
        --gfs-input-color: #111111;
        --gfs-check: #555555;
        --gfs-label: #555555;
        --gfs-desc: #444444;
        --gfs-warn-bg: #fff5d6;
        --gfs-warn-text: #8a6d00;
        --gfs-scroll-track: rgba(0, 0, 0, 0.05);
        --gfs-scroll-thumb: rgba(0, 0, 0, 0.2);
        --gfs-scroll-thumb-hover: rgba(0, 0, 0, 0.3);
        --gfs-code-bg: #f6f8fa;
        --gfs-code-text: #57606a;
        --gfs-scriptname: #444444;
        --gfs-versub: #888888;
        /* AI 回复(markdown) */
        --gfs-md-bg: #ffffff;
        --gfs-md-text: #24292f;
        --gfs-md-code-bg: rgba(175, 184, 193, 0.2);
        --gfs-md-code-text: #24292f;
        --gfs-md-pre-bg: rgba(0, 0, 0, 0.03);
        --gfs-md-heading: #0969da;
        --gfs-md-border: #d0d7de;
        --gfs-md-strong: #111111;
        --gfs-md-em: #444444;
        --gfs-md-link: #0969da;
        --gfs-md-block: #57606a;
    }
}

/* 亮色主题:手动强制(data-gfs-theme="light")最高优先 */
:root[data-gfs-theme="light"] {
    --gfs-bg: rgba(255, 255, 255, 0.96);
    --gfs-header-bg: rgba(0, 0, 0, 0.04);
    --gfs-panel-border: rgba(0, 0, 0, 0.12);
    --gfs-header-border: rgba(0, 0, 0, 0.06);
    --gfs-text: #1e1e2e;
    --gfs-title: #111111;
    --gfs-muted: #555555;
    --gfs-subtle: #6b6b6b;
    --gfs-hint: #6b7280;
    --gfs-ghost-bg: rgba(0, 0, 0, 0.05);
    --gfs-ghost-hover: rgba(0, 0, 0, 0.1);
    --gfs-ghost-color: #555555;
    --gfs-ghost-hover-color: #111111;
    --gfs-card-bg: rgba(240, 244, 250, 0.85);
    --gfs-card-title: #2563eb;
    --gfs-card-content: #333333;
    --gfs-input-bg: rgba(240, 240, 245, 0.9);
    --gfs-input-border: rgba(0, 0, 0, 0.15);
    --gfs-input-color: #111111;
    --gfs-check: #555555;
    --gfs-label: #555555;
    --gfs-desc: #444444;
    --gfs-warn-bg: #fff5d6;
    --gfs-warn-text: #8a6d00;
    --gfs-scroll-track: rgba(0, 0, 0, 0.05);
    --gfs-scroll-thumb: rgba(0, 0, 0, 0.2);
    --gfs-scroll-thumb-hover: rgba(0, 0, 0, 0.3);
    --gfs-code-bg: #f6f8fa;
    --gfs-code-text: #57606a;
    --gfs-scriptname: #444444;
    --gfs-versub: #888888;
    /* AI 回复(markdown) */
    --gfs-md-bg: #ffffff;
    --gfs-md-text: #24292f;
    --gfs-md-code-bg: rgba(175, 184, 193, 0.2);
    --gfs-md-code-text: #24292f;
    --gfs-md-pre-bg: rgba(0, 0, 0, 0.03);
    --gfs-md-heading: #0969da;
    --gfs-md-border: #d0d7de;
    --gfs-md-strong: #111111;
    --gfs-md-em: #444444;
    --gfs-md-link: #0969da;
    --gfs-md-block: #57606a;
}

/* ═══════════════════════════════════════════════════════════════
   悬浮窗系统样式
   ═══════════════════════════════════════════════════════════════ */
.gfs-panel {
    position: fixed;
    background: var(--gfs-bg);
    backdrop-filter: blur(12px);
    color: var(--gfs-text);
    border: 1px solid var(--gfs-panel-border);
    border-radius: 12px;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
    font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
    font-size: 13px;
    line-height: 1.5;
    z-index: 99999;
    transition: all 0.3s ease;
    overflow: hidden;
}

.gfs-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 10px 14px;
    background: var(--gfs-header-bg);
    cursor: move;
    user-select: none;
    border-bottom: 1px solid var(--gfs-header-border);
}

.gfs-title {
    font-weight: 600;
    font-size: 14px;
    color: var(--gfs-title);
}

.gfs-controls {
    display: flex;
    gap: 6px;
}

.gfs-btn-icon {
    width: 24px;
    height: 24px;
    border: none;
    background: var(--gfs-ghost-bg);
    color: var(--gfs-ghost-color);
    border-radius: 4px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.2s;
    font-size: 14px;
}

.gfs-btn-icon:hover {
    background: var(--gfs-ghost-hover);
    color: var(--gfs-ghost-hover-color);
}

.gfs-body {
    padding: 14px;
    max-height: 500px;
    overflow-y: auto;
}

.gfs-panel.collapsed .gfs-body {
    display: none;
}

.gfs-panel.minimized {
    width: 48px !important;
    height: 48px !important;
    border-radius: 50%;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
}

.gfs-panel.minimized::after {
    content: attr(data-icon);
    font-size: 22px;
    line-height: 1;
}

.gfs-panel.minimized .gfs-header,
.gfs-panel.minimized .gfs-body {
    display: none;
}

.gfs-body::-webkit-scrollbar {
    width: 6px;
}

.gfs-body::-webkit-scrollbar-track {
    background: var(--gfs-scroll-track);
}

.gfs-body::-webkit-scrollbar-thumb {
    background: var(--gfs-scroll-thumb);
    border-radius: 3px;
}

.gfs-body::-webkit-scrollbar-thumb:hover {
    background: var(--gfs-scroll-thumb-hover);
}

/* 卡片样式 */
.gfs-card {
    background: var(--gfs-card-bg);
    border-left: 3px solid #4fc3f7;
    border-radius: 4px;
    padding: 10px 12px;
    margin-bottom: 10px;
}

.gfs-card-title {
    font-weight: 600;
    color: var(--gfs-card-title);
    margin-bottom: 6px;
    font-size: 12px;
}

.gfs-card-content {
    font-size: 12px;
    color: var(--gfs-card-content);
}

/* 按钮样式 */
.gfs-btn {
    padding: 8px 14px;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-weight: 600;
    font-size: 12px;
    transition: all 0.2s;
    display: inline-flex;
    align-items: center;
    gap: 6px;
}

.gfs-btn-primary {
    background: #1565c0;
    color: #fff;
}

.gfs-btn-primary:hover {
    background: #1976d2;
}

.gfs-btn-ai {
    background: #667eea;
    color: #fff;
}

.gfs-btn-ai:hover {
    background: #7c8cf0;
}

.gfs-btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

.gfs-select, .gfs-input {
    background: var(--gfs-input-bg);
    color: var(--gfs-input-color);
    border: 1px solid var(--gfs-input-border);
    padding: 6px 10px;
    border-radius: 4px;
    font-size: 12px;
}

.gfs-select:focus, .gfs-input:focus {
    outline: none;
    border-color: rgba(102, 126, 234, 0.5);
}

.gfs-status-indicator {
    display: inline-block;
    width: 10px;
    height: 10px;
    border-radius: 50%;
    margin-right: 6px;
}

.gfs-fab {
    position: fixed;
    bottom: 20px;
    right: 20px;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background: #667eea;
    color: #fff;
    border: none;
    cursor: pointer;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 24px;
    z-index: 99998;
    transition: all 0.3s;
}

.gfs-fab:hover {
    transform: scale(1.1);
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}

/* ═══════════════════════════════════════════════════════════════
   AI 回复(markdown)样式
   ═══════════════════════════════════════════════════════════════ */
#gfr { background: var(--gfs-md-bg) !important; color: var(--gfs-md-text) !important; }
#gfr p   { margin:4px 0; }
#gfr ul, #gfr ol { padding-left:20px; margin:4px 0; }
#gfr li  { margin:2px 0; }
#gfr code {
    background: var(--gfs-md-code-bg); color: var(--gfs-md-code-text);
    border:1px solid var(--gfs-md-border); border-radius:3px;
    padding:1px 5px; font-family:'Consolas','Courier New',monospace;
    font-size:11px; word-break:break-word; }
#gfr pre { background: var(--gfs-md-pre-bg); border:1px solid var(--gfs-md-border);
    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: var(--gfs-md-heading); font-size:14px; margin:10px 0 4px;
    border-bottom:1px solid var(--gfs-md-border); padding-bottom:3px; }
#gfr h3,#gfr h4 { color: var(--gfs-md-heading); font-size:13px; margin:8px 0 3px; }
#gfr strong,#gfr b { color: var(--gfs-md-strong); font-weight:600; }
#gfr em,#gfr i    { color: var(--gfs-md-em); font-style:italic; }
#gfr a { color: var(--gfs-md-link); text-decoration:none; }
#gfr a:hover { text-decoration:underline; }
#gfr blockquote { border-left:3px solid var(--gfs-md-border); padding:4px 8px; color: var(--gfs-md-block);
    margin:4px 0; background: var(--gfs-md-pre-bg); border-radius:0 4px 4px 0; }
#gfr hr { border:none; border-top:1px solid var(--gfs-md-border); margin:8px 0; }
#gfr table { border-collapse:collapse; width:100%; margin:6px 0; font-size:12px; }
#gfr th { background: var(--gfs-md-code-bg); color: var(--gfs-md-text);
    padding:4px 8px; border:1px solid var(--gfs-md-border); }
#gfr td { padding:4px 8px; border:1px solid var(--gfs-md-border); color: var(--gfs-md-text); }
#gfr tr:nth-child(even) td { background: var(--gfs-md-pre-bg); }

/* ═══════════════════════════════════════════════════════════════
   AI 结构化报告样式(自动适配明暗主题)
   ═══════════════════════════════════════════════════════════════ */
.gfs-ai-verdict { display:flex; align-items:center; gap:8px; border:2px solid; border-radius:8px; padding:8px 12px; margin-bottom:4px; }
.gfs-ai-vlabel  { font-size:16px; font-weight:800; }
.gfs-ai-vreason { font-size:12px; margin:0 0 6px; color: var(--gfs-md-text); }
.gfs-ai-sec      { margin:12px 0 4px; font-size:12px; font-weight:700; letter-spacing:.5px; color: var(--gfs-md-heading); }
.gfs-ai-item     { background: var(--gfs-md-pre-bg); border:1px solid var(--gfs-md-border); border-left:3px solid; border-radius:6px; padding:8px 10px; margin:6px 0; }
.gfs-ai-item-head { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.gfs-ai-item-name { font-size:13px; font-weight:700; color: var(--gfs-md-strong); }
.gfs-ai-badge    { display:inline-block; padding:1px 8px; border-radius:10px; font-size:11px; font-weight:600; color:#fff; }
.gfs-ai-reason   { font-size:12px; margin-top:4px; white-space:pre-wrap; color: var(--gfs-md-text); }
.gfs-ai-evid     { font-size:11px; margin-top:4px; font-family:'Consolas','Courier New',monospace; white-space:pre-wrap; color: var(--gfs-md-block); }
.gfs-ai-summary summary { cursor:pointer; font-size:12px; font-weight:600; color: var(--gfs-md-heading); }
.gfs-ai-think    { border-left:2px solid #90caf9; padding-left:10px; margin-top:6px; font-size:12px; white-space:pre-wrap; color: var(--gfs-md-block); }
.gfs-ai-trunc    { background: var(--gfs-warn-bg); color: var(--gfs-warn-text); padding:6px 10px; border-radius:6px; font-size:12px; margin-bottom:6px; }
/* AI 结果全屏放大查看:#gfr 挂载到 body 上的覆盖层(面板的 backdrop-filter
   会成为 fixed 子元素的包含块,故放大时把节点移出面板再定位) */
#gfr.gfs-expanded {
    position: fixed !important;
    inset: 16px !important;
    width: auto !important;
    max-height: none !important;
    padding: 24px !important;
    border: 1px solid var(--gfs-panel-border) !important;
    border-radius: 12px !important;
    background: var(--gfs-bg) !important;
    box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
    font-size: 14px !important;
    overflow-y: auto;
    z-index: 100001;
}
.gfs-zoom-restore {
    position: fixed;
    top: 28px;
    right: 32px;
    z-index: 100002;
    white-space: nowrap;
}`;
        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 });
        }
    };

    // ══════════════════════════════════════════════════════════════════════════
    //  AI 模型列表
    // ══════════════════════════════════════════════════════════════════════════
    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 },

        claude:      { n: 'Claude 💰',
                       u: 'https://api.anthropic.com/v1/messages',
                       m: 'claude-3-5-sonnet-20241022',
                       claude: true,
                       keyLabel: 'Anthropic (console.anthropic.com/settings/keys)' },

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

        custom:      { n: '🔌 自定义 API',
                       u: '', m: '',
                       custom: true,
                       keyLabel: '你的 API 密钥',
                       keyUrl: '' },
    };

    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',
        claude:     'https://console.anthropic.com/settings/keys',
        mistral_or: 'https://openrouter.ai/keys'
    };

    // ══════════════════════════════════════════════════════════════════════════
    //  AI 回复语言
    // ══════════════════════════════════════════════════════════════════════════
    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:'Язык ответа:',
            getKey:'Получить ключ', keyBtnSave:'Сохранить токен', keyBtnUpdate:'Обновить токен',
            keySaved:'🔑 Ключ сохранён в GM-хранилище · введите новый, чтобы обновить',
            loadingPuter:'Загрузка Puter.js, подключение к Llama...',
            puterErr:'Ошибка Puter.js',
            keysCleared:'✅ Все API-ключи очищены (CLEAR_ALL_API_KEYS=true).',
            describeScript:'Расскажи о скрипте',
            customCfg:'Свой API', customUrl:'API URL (chat/completions)', customModel:'Модель', customToken:'Token (может быть пустым)',
            customConnect:'⚠️ Добавьте домен {d} в список @connect в шапке скрипта',
            customConnectNone:'⚠️ Укажите адрес API и добавьте его домен в @connect в шапке скрипта',
            manualMenu:'Вставить скрипт для анализа', manualTitle:'Анализ вставленного скрипта',
            manualPlaceholder:'Вставьте сюда исходный код скрипта (UserScript)...', manualAnalyze:'Проанализировать',
            manualSrc:'Ручной ввод', manualEmpty:'Сначала вставьте код скрипта',
            suspicious:'Подозрительно', jConfirmed:'Подтверждено', jFalse:'Ложное срабатывание', jUncertain:'Неопределённо',
            aiThinking:'Рассуждения ИИ', aiFindings:'Проверка находок сканера', aiMissed:'Пропущенные угрозы',
            aiDomains:'Анализ доменов', aiDesc:'Что делает скрипт', aiHumor:'Вывод с юмором',
            aiTruncated:'Ответ ИИ обрезан — JSON восстановлен автоматически, результат может быть неполным',
            zoomIn:'Увеличить отчёт ИИ', zoomOut:'Свернуть (Esc)'
        },
        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:',
            getKey:'Get key', keyBtnSave:'Save token', keyBtnUpdate:'Update token',
            keySaved:'🔑 Key saved in GM storage · enter a new one to update',
            loadingPuter:'Loading Puter.js, connecting to Llama...',
            puterErr:'Puter.js error',
            keysCleared:'✅ All API keys cleared (CLEAR_ALL_API_KEYS=true).',
            describeScript:'Describe script',
            customCfg:'Custom API', customUrl:'API URL (chat/completions)', customModel:'Model', customToken:'Token (optional)',
            customConnect:'⚠️ Add domain {d} to the @connect list in the script header',
            customConnectNone:'⚠️ Enter your API URL and add its domain to the @connect list in the script header',
            manualMenu:'Paste a script to analyze', manualTitle:'Analyze pasted script',
            manualPlaceholder:'Paste the UserScript source code here...', manualAnalyze:'Analyze',
            manualSrc:'Manual input', manualEmpty:'Paste a script first',
            suspicious:'Suspicious', jConfirmed:'Confirmed', jFalse:'False Positive', jUncertain:'Uncertain',
            aiThinking:'AI Reasoning', aiFindings:'Scanner Findings Review', aiMissed:'Missed Threats',
            aiDomains:'Domain Risk Analysis', aiDesc:'What This Script Does', aiHumor:'Fun Conclusion',
            aiTruncated:'AI response was truncated — JSON auto-repaired, results may be incomplete',
            zoomIn:'Enlarge AI report', zoomOut:'Restore (Esc)'
        },
        zh: {
            report:'报告', safe:'安全', low:'低风险', med:'中风险', danger:'危险',
            issues:'威胁', domains:'域名', showUrls:'显示 URL', showLines:'显示代码行',
            noThreats:'未发现威胁。', retryLocal:'重新扫描', checkAi:'使用 AI 检查',
            retryAi:'再次 AI', sending:'正在发送数据到', thinking:'思考中...',
            noKey:'未输入密钥', enterKey:'请输入 API 密钥用于', parseErr:'响应解析错误',
            netErr:'网络错误。请查看控制台 (F12)。', emptyResp:'AI 返回空响应。',
            modelLabel:'AI 模型:', langLabel:'响应语言:',
            getKey:'获取Token', keyBtnSave:'保存token', keyBtnUpdate:'更新token',
            keySaved:'🔑 密钥已保存在 GM 存储 · 输入新值以更新',
            loadingPuter:'正在加载 Puter.js,连接 Llama...',
            puterErr:'Puter.js 错误',
            keysCleared:'✅ 所有 API 密钥已清除 (CLEAR_ALL_API_KEYS=true)。',
            describeScript:'描述该脚本',
            customCfg:'自定义 API', customUrl:'API 地址 (chat/completions)', customModel:'模型', customToken:'Token(可为空)',
            customConnect:'⚠️ 请将该域名加入脚本头部的 @connect:{d}',
            customConnectNone:'⚠️ 请在脚本头部的 @connect 中加入自有 API 的域名',
            manualMenu:'分析脚本', manualTitle:'分析粘贴的脚本',
            manualPlaceholder:'在此粘贴要分析的脚本源码(UserScript)...', manualAnalyze:'开始分析',
            manualSrc:'手动输入', manualEmpty:'请先粘贴脚本代码',
            suspicious:'可疑', jConfirmed:'确认威胁', jFalse:'误报', jUncertain:'存疑',
            aiThinking:'AI 分析思路', aiFindings:'本地告警复核', aiMissed:'漏检威胁',
            aiDomains:'域名风险分析', aiDesc:'脚本功能', aiHumor:'趣味结论',
            aiTruncated:'AI 回复被截断,JSON 已自动修复补全,结果可能不完整',
            zoomIn:'放大查看 AI 报告', zoomOut:'还原 (Esc)'
        }
    };

    const t = (k) => (I18N[L] || I18N.en)[k] ?? I18N.en[k] ?? k;

    // ══════════════════════════════════════════════════════════════════════════
    //  本地扫描规则 —— 预编译正则
    // ══════════════════════════════════════════════════════════════════════════
    const RULES = [
        { p:/\beval\s*\(/g,                            v:4, en:'eval()',          ru:'eval()',           den:'Code Execution',        dru:'Выполнение кода',   zh:'eval()',            dzh:'代码执行'   },
        { p:/\bnew\s+Function\s*\(/g,                  v:4, en:'new Function()',  ru:'new Function()',   den:'Dynamic Code',          dru:'Динамика',          zh:'new Function()',    dzh:'动态代码'     },
        { p:/coinhive|cryptonight|monero|minero\.js/i, v:4, en:'Miner',           ru:'Майнер',           den:'Crypto Mining',         dru:'Криптомайнинг',     zh:'矿工脚本',          dzh:'加密货币挖矿' },
        { p:/\batob\s*\(/g,                            v:3, en:'atob()',          ru:'atob()',           den:'Hidden Base64',         dru:'Скрытый Base64',    zh:'atob()',            dzh:'隐藏 Base64'    },
        { p:/_0x[a-f0-9]{4,}/i,                        v:3, en:'Obfuscation',     ru:'Обфускация',       den:'Obfuscated Code',       dru:'Запутанный код',    zh:'代码混淆',          dzh:'混淆代码'       },
        { p:/String\.fromCharCode/i,                   v:3, en:'CharCode',        ru:'CharCode',         den:'Obfuscation Technique', dru:'Техника обфускации',zh:'CharCode',          dzh:'混淆技术'       },
        { p:/\bnavigator\.geolocation\b/g,             v:3, en:'Geolocation',     ru:'Геолокация',       den:'GPS Tracking',          dru:'Отслеживание GPS',  zh:'定位 Geolocation',  dzh:'GPS 定位追踪'     },
        { p:/\bnavigator\.mediaDevices\b/g,            v:3, en:'Cam/Mic',         ru:'Кам/Мик',          den:'Camera/Microphone',     dru:'Камера/Микрофон',   zh:'摄像头/麦克风',     dzh:'摄像头/麦克风'     },
        { p:/\bGM_xmlhttpRequest\b/g,                  v:3, en:'GM_XHR',          ru:'GM_XHR',           den:'Hidden XHR Requests',   dru:'Скрытые запросы',   zh:'GM_XHR',            dzh:'隐藏请求'       },
        { p:/\bdocument\.cookie\b/g,                   v:2, en:'Cookies',         ru:'Cookies',          den:'Cookie Access',         dru:'Доступ к куки',     zh:'Cookies',           dzh:'访问 Cookie'      },
        { p:/\blocalStorage\b/g,                       v:2, en:'LocalStorage',    ru:'LocalStorage',     den:'Persistent Storage',    dru:'Локальное хранение',zh:'LocalStorage',      dzh:'本地持久化存储'      },
        { p:/\bsessionStorage\b/g,                     v:2, en:'SessionStorage',  ru:'SessionStorage',   den:'Session Storage',       dru:'Сессионное хранение',zh:'SessionStorage',   dzh:'会话存储'      },
        { p:/\bnavigator\.clipboard\b/g,               v:2, en:'Clipboard',       ru:'Буфер обмена',     den:'Clipboard Access',      dru:'Буфер обмена',      zh:'剪贴板',            dzh:'剪贴板访问'       },
        { p:/\bGM_getValue\b|\bGM_setValue\b/g,        v:2, en:'GM_Store',        ru:'GM_Store',         den:'TM/GM Storage API',     dru:'Хранилище TM/GM',   zh:'GM_Store',          dzh:'TM/GM 存储 API'     },
        { p:/\bfetch\s*\(/g,                           v:1, en:'Fetch',           ru:'Fetch',            den:'Network Request',       dru:'Сетевой запрос',    zh:'Fetch',             dzh:'网络请求'       },
        { p:/\bXMLHttpRequest\b/g,                     v:1, en:'XHR',             ru:'XHR',              den:'Network Request',       dru:'Сетевой запрос',    zh:'XHR',               dzh:'网络请求'       },
        { p:/\bdocument\.write\s*\(/g,                 v:2, en:'DocWrite',        ru:'DocWrite',         den:'Page Overwrite',        dru:'Перезапись страницы',zh:'DocWrite',        dzh:'页面覆盖写入'    },
        // ── 扩充:常见恶意行为模式 ───────────────────────────────────────────
        { p:/\bRTCPeerConnection\b/g,                  v:3, en:'WebRTC',          ru:'WebRTC',          den:'IP Leak via WebRTC',    dru:'Утечка IP',         zh:'WebRTC',            dzh:'WebRTC 泄漏真实 IP' },
        { p:/addEventListener\s*\(\s*(['"]keydown|['"]keypress)/g, v:2, en:'Keylog', ru:'Keylog',          den:'Keylogger (keyboard)',  dru:'Кейлоггер',        zh:'键盘记录',          dzh:'监听键盘按键' },
        { p:/\bnavigator\.mediaDevices\.getDisplayMedia\b/g, v:3, en:'DisplayCapture', ru:'ScreenCapture', den:'Screen Capture',     dru:'Захват экрана',     zh:'屏幕捕获',          dzh:'录屏捕获' },
        { p:/\bnavigator\.mediaDevices\.getUserMedia\b/g, v:3, en:'getUserMedia',  ru:'getUserMedia',    den:'Cam/Mic Capture',       dru:'Захват камеры',     zh:'摄像头/麦克风采集', dzh:'采集摄像头/麦克风' },
        { p:/\bnavigator\.sendBeacon\b/g,              v:2, en:'Beacon',          ru:'Beacon',          den:'Background Beacon',     dru:'Фоновая отправка',  zh:'Beacon 上报',       dzh:'后台上报数据' },
        { p:/\bnew\s+WebSocket\b/g,                    v:1, en:'WebSocket',       ru:'WebSocket',       den:'Socket Channel',        dru:'Сокет-канал',       zh:'WebSocket',         dzh:'网络通道' },
        { p:/location\.(href\s*=|assign\s*\(|replace\s*\()/g, v:2, en:'Redirect',  ru:'Redirect',        den:'Page Redirect',         dru:'Перенаправление',   zh:'页面跳转',          dzh:'跳转/钓鱼' },
        { p:/\bGM_download\b|\bGM\.download\b/g,       v:2, en:'GM_Download',     ru:'GM_Download',     den:'File Download API',     dru:'Скачивание файла',  zh:'GM_Download',       dzh:'触发文件下载' },
        { p:/navigator\.clipboard\.writeText|setClipboard/g, v:2, en:'ClipboardWrite', ru:'ClipboardWrite', den:'Clipboard Hijack',   dru:'Замена буфера',     zh:'剪贴板写入',        dzh:'覆盖/篡改剪贴板' },
        { p:/\bNotification\.requestPermission\b/g,    v:1, en:'Notifications',   ru:'Notification',    den:'Push Notifications',    dru:'Уведомления',       zh:'通知推送',          dzh:'请求通知权限' },
        { p:/(canvas|webgl).*toDataURL|toBlob\)/gi,    v:2, en:'Fingerprint',     ru:'Fingerprint',     den:'Device Fingerprinting',  dru:'Отпечаток устройст.',zh:'设备指纹',        dzh:'canvas 指纹采集' },
        { p:/data:\s*text\/(javascript|html)/gi,       v:2, en:'DataURI',         ru:'DataURI',         den:'Data URI Script',       dru:'Data-URI скрипт',   zh:'Data URI',          dzh:'隐蔽执行 data URI' },
        { p:/setTimeout\s*\(['"]/g,                   v:2, en:'StrTimer',        ru:'StrTimer',        den:'String Exec Timer',     dru:'Таймер-строка',     zh:'字符串定时器',      dzh:'字符串形式延迟执行' },
        { p:/\.createElement\(\s*(['"])script|importScripts\s*\(/g, v:2, en:'InjectScript', ru:'InjectScript', den:'Dynamic Script Inject', dru:'Инъекция скрипта', zh:'动态注入脚本',      dzh:'运行时注入外部脚本' },
        { p:/\bunsafeWindow\b/g,                       v:2, en:'unsafeWindow',    ru:'unsafeWindow',    den:'Privilege Channel',      dru:'Канал привилегий',  zh:'unsafeWindow',      dzh:'提升权限通道' },
        { p:/(\\x[0-9a-fA-F]{2}){4,}|\\(\d{3})/g,      v:3, en:'HexObfuscation',  ru:'HexObfuscation',  den:'Heavy Obfuscation',      dru:'Сильная обфускация',zh:'密集混淆',          dzh:'密集 hex/octal 混淆' }
    ].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,'&').replace(//g,'>').replace(/"/g,'"') : '';

    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] };
    };

    // ── 混淆载荷深度解码 ─────────────────────────────────────────────────────
    // 纯 JS Base64 解码(兼容跨 realm 环境)
    const b64decode = (s) => {
        try {
            const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
            let str = s.replace(/\s/g, '');
            if (!/^[A-Za-z0-9+/]*={0,2}$/.test(str)) return null;
            const out = []; let b = 0, bits = 0;
            for (let i = 0; i < str.length; i++) {
                if (str[i] === '=') break;
                const c = chars.indexOf(str[i]);
                if (c < 0) return null;
                b = (b << 6) | c; bits += 6;
                if (bits >= 8) { bits -= 8; out.push((b >> bits) & 0xFF); }
            }
            return new TextDecoder().decode(new Uint8Array(out));
        } catch(e) { return null; }
    };

    // 解码还原混淆字符串:atob("\xHH" / charCode / unicode
    const decodeObfuscation = (code) => {
        if (!code || code.length > MAX_DECODE * 5) return null;
        let changed = false;

        // 1) atob("...") / atob('...')
        const atobRe = /atob\s*\(\s*(["'])([^"']+?)\1\s*\)/g;
        code = code.replace(atobRe, (m, q, inner) => {
            const d = b64decode(inner);
            if (d) { changed = true; return d; }
            return m;
        });

        // 2) \xHH 连续转义
        const hexRe = /\\x([0-9a-fA-F]{2})/g;
        if (hexRe.test(code)) {
            code = code.replace(hexRe, (m, h) => { changed = true; return String.fromCharCode(parseInt(h, 16)); });
        }

        // 3) String.fromCharCode(N, N, ...)
        const fccRe = /String\.fromCharCode\s*\(([^)]+)\)/g;
        code = code.replace(fccRe, (m, args) => {
            const nums = args.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n >= 0 && n <= 0x10FFFF);
            if (nums.length) { changed = true; return String.fromCharCode(...nums); }
            return m;
        });

        // 4) \uXXXX unicode 转义
        const uniRe = /\\u([0-9a-fA-F]{4})/g;
        if (uniRe.test(code)) {
            code = code.replace(uniRe, (m, u) => { changed = true; return String.fromCharCode(parseInt(u, 16)); });
        }

        return changed ? code.substring(0, MAX_DECODE) : null;
    };

    // 多信号关联加权评分(v1.7.0 放宽评级标准:
    // 单条常见 API 命中(GM_xmlhttpRequest/atob/localStorage/GM_getValue 等)
    // 不再直接定档,改为纯累积分数分档,防止大量正常脚本被误报为中/高危)
    const computeRisk = (findings, net) => {
        let maxV = 0;
        let score = 0;
        // 标记命中集合(用于组合判定)
        const hitSet = new Set();

        findings.forEach(f => {
            if (f.v > maxV) maxV = f.v;
            // 非线性计分
            const baseScore = f.v === 1 ? 1 : f.v === 2 ? 2 : f.v === 3 ? 4 : 6;
            // 轻度加权:单次命中=1.0,重复命中略增但封顶,避免误报过度堆积
            const countWeight = 1 + 0.15 * Math.min(Math.max(f.count || 1, 1) - 1, 4);
            score += baseScore * countWeight;
            hitSet.add(f.zh);
        });

        // 组合加成(真实恶意组合的加权更高)
        const hasDynamic = hitSet.has('eval()') || hitSet.has('new Function()') || hitSet.has('动态代码');
        const hasObfuscation = hitSet.has('atob()') || hitSet.has('代码混淆') || hitSet.has('CharCode') || hitSet.has('混淆技术') || hitSet.has('密集混淆');
        const hasExfilChannel = hitSet.has('Fetch') || hitSet.has('XHR') || hitSet.has('GM_XHR') || hitSet.has('WebSocket') || hitSet.has('Beacon 上报');
        const hasCapture = hitSet.has('键盘记录') || hitSet.has('屏幕捕获') || hitSet.has('摄像头/麦克风') || hitSet.has('摄像头/麦克风采集');

        if (hasDynamic && hasObfuscation) score += 4; // 混淆动态执行
        if (hasObfuscation && hasExfilChannel) score += 3; // 隐藏数据外发
        if (hasCapture && hasExfilChannel) score += 4; // 窃取数据外传

        // 分档:纯累积分数制(阈值整体放宽)—— safe <8 ≤ low <16 ≤ med <26 ≤ danger
        let level, col, status;
        const setLevel = (lv) => {
            level = lv;
            col    = lv === 'danger' ? '#f44336' : lv === 'med' ? '#ff9800' : lv === 'low' ? '#ffd700' : '#4caf50';
            status = I18N[L] ? I18N[L][lv] : I18N.en[lv];
        };
        if (score >= 26)      setLevel('danger');
        else if (score >= 16) setLevel('med');
        else if (score >= 8)  setLevel('low');
        else                  setLevel('safe');

        // 底线检测(防放宽后漏报):
        // 1) 单条极高危信号(eval/挖矿等 v4)保底低危
        if (maxV >= 4 && level === 'safe') setLevel('low');
        // 2) 强恶意组合保底中危:「采集类 + 外发通道」「动态执行 + 混淆」
        //    这类组合在正常脚本中几乎不会同时出现
        if ((hasCapture && hasExfilChannel) || (hasDynamic && hasObfuscation)) {
            if (level === 'safe' || level === 'low') setLevel('med');
        }

        return { score: Math.round(score * 10) / 10, maxV, level, col, status };
    };

    // 对一段源码执行规则扫描,返回发现与风险等级(主脚本与依赖共用)
    const scanCode = (src) => {
        const findings = []; let maxV = 0;
        RULES.forEach(r => {
            r.re.lastIndex = 0;
            const m = src.match(r.re);
            if (m) {
                findings.push({ ...r, count: m.length, lines: findLines(src, r.re) });
                if (r.v > maxV) maxV = r.v;
            }
        });
        // 二次扫描:解码混淆后对还原内容再检
        const decoded = decodeObfuscation(src);
        if (decoded) {
            RULES.forEach(r => {
                r.re.lastIndex = 0;
                const m = decoded.match(r.re);
                if (m) {
                    // 去重:避免与原始命中重复计数
                    const existing = findings.find(f => f.en === r.en);
                    if (existing) {
                        existing.count += m.length;
                        existing.decoded = true;
                        const extraLines = findLines(decoded, r.re);
                        existing.lines = [...new Set([...existing.lines, ...extraLines])].slice(0, MAX_FIND_LINES);
                    } else {
                        findings.push({ ...r, count: m.length, lines: findLines(decoded, r.re), decoded: true });
                        if (r.v > maxV) maxV = r.v;
                    }
                }
            });
        }
        const net = extractUrls(src);
        const risk = computeRisk(findings, net);
        return { findings, maxV: risk.maxV, net, score: risk.score, level: risk.level, col: risk.col, status: risk.status };
    };

    // ══════════════════════════════════════════════════════════════════════════
    //  悬浮窗组件系统
    //  createPanel() 生成一个可拖拽、可折叠、可最小化、可关闭的悬浮窗对象
    //  ══════════════════════════════════════════════════════════════════════════
    const panels = {};          // 记录已创建的所有悬浮窗 { id: obj }
    const fabIcon = '🛡️';      // 全局入口按钮图标

    // 创建(或复用)一个悬浮窗
    const createPanel = (opts) => {
        if (panels[opts.id]) return panels[opts.id];

        const el = document.createElement('div');
        el.id = 'gfs-panel-' + opts.id;
        el.className = 'gfs-panel';
        el.dataset.icon = opts.icon || '🛡️';   // 最小化为圆球时显示的居中图标
        el.style.width = (opts.width || 320) + 'px';
        // 位置:指定了 x/y 用 left/top,否则默认右下角用 right/bottom
        if (opts.x != null) { el.style.left = opts.x + 'px'; }
        else               { el.style.right = '20px'; }
        if (opts.y != null) { el.style.top = opts.y + 'px'; }
        else               { el.style.bottom = '20px'; }
        if (opts.visible === false) el.style.display = 'none';

        // 标题栏
        const header = document.createElement('div');
        header.className = 'gfs-header';
        const title = document.createElement('span');
        title.className = 'gfs-title';
        title.textContent = (opts.icon || '') + ' ' + (opts.title || '');
        const controls = document.createElement('div');
        controls.className = 'gfs-controls';

        // 折叠按钮
        const btnMin = document.createElement('button');
        btnMin.className = 'gfs-btn-icon';
        btnMin.textContent = '—';
        btnMin.title = '折叠/展开';
        controls.appendChild(btnMin);

        // 最小化按钮
        const btnCollapse = document.createElement('button');
        btnCollapse.className = 'gfs-btn-icon';
        btnCollapse.textContent = '⊖';
        btnCollapse.title = '最小化';
        controls.appendChild(btnCollapse);

        // 关闭按钮
        const btnClose = document.createElement('button');
        btnClose.className = 'gfs-btn-icon';
        btnClose.textContent = '✕';
        btnClose.title = '关闭';
        controls.appendChild(btnClose);

        header.appendChild(title);
        header.appendChild(controls);

        // 内容区
        const body = document.createElement('div');
        body.className = 'gfs-body';
        body.id = 'gfs-body-' + opts.id;

        el.appendChild(header);
        el.appendChild(body);
        (document.body || document.documentElement).appendChild(el);

        // ── 拖拽逻辑(仅标题栏;兼容鼠标与触摸) ─────────────────────────────
        let dragging = false, startX = 0, startY = 0, origL = 0, origT = 0;
        const isTitleTarget = (t) => (t === title || t === header || !!(t && t.closest && t.closest('.gfs-title')));
        const dragStart = (clientX, clientY) => {
            if (!isTitleTarget(dragStart._t)) return;
            dragging = true;
            startX = clientX; startY = clientY;
            const cs = getComputedStyle(el);
            origL = parseInt(cs.left) || el.offsetLeft || 0;
            origT = parseInt(cs.top)  || el.offsetTop  || 0;
            el.style.right = 'auto';
            el.style.bottom = 'auto';
        };
        const dragMove = (clientX, clientY) => {
            if (!dragging) return;
            let nx = origL + (clientX - startX);
            let ny = origT + (clientY - startY);
            nx = Math.max(0, Math.min(nx, window.innerWidth  - el.offsetWidth  - 10));
            ny = Math.max(0, Math.min(ny, window.innerHeight - el.offsetHeight - 10));
            el.style.left = nx + 'px';
            el.style.top  = ny + 'px';
        };
        const dragEnd = () => {
            if (!dragging) return;
            dragging = false;
            savePanelPos(opts.id, el);
        };
        header.addEventListener('mousedown', (e) => { dragStart._t = e.target; dragStart(e.clientX, e.clientY); if (dragging) e.preventDefault(); });
        document.addEventListener('mousemove', (e) => dragMove(e.clientX, e.clientY));
        document.addEventListener('mouseup', dragEnd);
        // 触摸拖拽
        header.addEventListener('touchstart', (e) => {
            const t = e.touches && e.touches[0];
            if (!t) return;
            dragStart._t = e.target;
            dragStart(t.clientX, t.clientY);
            if (dragging) e.preventDefault();
        }, { passive: false });
        document.addEventListener('touchmove', (e) => {
            const t = e.touches && e.touches[0];
            if (t) { if (dragging) e.preventDefault(); dragMove(t.clientX, t.clientY); }
        }, { passive: false });
        document.addEventListener('touchend', dragEnd);
        document.addEventListener('touchcancel', dragEnd);

        // ── 折叠 / 最小化 / 关闭 ─────────────────────────────────────────────
        btnMin.addEventListener('click', (e) => { e.stopPropagation(); el.classList.toggle('collapsed'); });
        btnCollapse.addEventListener('click', (e) => {
            e.stopPropagation();
            const wasMin = el.classList.contains('minimized');
            el.classList.toggle('minimized');
            if (!wasMin) { // 最小化后整个面板可点击恢复
                el.onclick = () => { el.classList.remove('minimized'); el.onclick = null; };
            }
        });
        // 最小化状态点击整体恢复
        el.addEventListener('click', (e) => {
            if (el.classList.contains('minimized') && e.target.tagName !== 'BUTTON') {
                el.classList.remove('minimized');
            }
        });
        btnClose.addEventListener('click', (e) => {
            e.stopPropagation();
            el.style.display = 'none';
            showFab();
        });

        const obj = {
            el, body, title,
            show()  { el.style.display = ''; },
            hide()  { el.style.display = 'none'; },
            toggle(){ el.style.display = (el.style.display === 'none') ? '' : 'none'; },
            destroy(){ if (panels[opts.id]) delete panels[opts.id]; el.remove(); }
        };
        panels[opts.id] = obj;
        return obj;
    };

    // 保存悬浮窗位置(使用 GM 存储持久化)
    const savePanelPos = (id, el) => {
        try {
            gmSet('panelpos_' + id, { l: parseInt(el.style.left) || null, t: parseInt(el.style.top) || null });
        } catch (e) {}
    };

    // ── 全局入口按钮(盾牌图标) ─────────────────────────────────────────────
    let _fab = null;
    const ensureFab = () => {
        if (_fab || document.getElementById('gfs-fab')) return _fab;
        const fab = document.createElement('button');
        fab.id = 'gfs-fab';
        fab.className = 'gfs-fab';
        fab.textContent = fabIcon;
        fab.title = '脚本安全扫描器';
        fab.addEventListener('click', () => {
            Object.values(panels).forEach(p => p.show());
            fab.style.display = 'none';
        });
        document.body.appendChild(fab);
        _fab = fab;
        return fab;
    };
    const showFab = () => { const f = ensureFab(); f.style.display = ''; };

    // ══════════════════════════════════════════════════════════════════════════
    //  加载已保存的设置
    // ══════════════════════════════════════════════════════════════════════════
    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', 'zh');  if (!LANGS[L])  L = 'zh';
    // v1.2.0: 「描述该脚本」复选框——仅本地,不在页面间保存
    let D = false;

    // 自定义 API 配置(地址、模型名),持久化保存在 GM 存储
    let CURL   = await gmGet('cu', '');
    let CMODEL = await gmGet('cm', '');

    // Token 输入框辅助:返回指定模型当前应显示的密钥(共享 key 取共享槽位)
    const keySlotOf = (s) => (MODELS[s].sharedKey || s);
    const keyForModel = (s) => (s === 'custom' ? (K.custom || '') : (K[keySlotOf(s)] || ''));
    // 指定模型对应的 Token 获取链接(无则返回空串,隐藏链接)
    const keyUrlOf = (s) => (KEY_URLS[keySlotOf(s)] || '');
    const keyPlace = (s) => {
        if (s === 'custom') return t('customToken');
        const cfg = MODELS[s];
        return (cfg.keyLabel || (cfg.n + ' Key')) + (KEY_URLS[keySlotOf(s)] ? ' (' + KEY_URLS[keySlotOf(s)].replace(/^https?:\/\//, '') + ')' : '');
    };
    // Token 输入框占位文案:已存密钥则不回显明文,仅提示已保存;未存则提示密钥来源
    const keyInputPlaceholder = (s) => (keyForModel(s) ? t('keySaved') : keyPlace(s));

    // ══════════════════════════════════════════════════════════════════════════
    //  诊断
    // ══════════════════════════════════════════════════════════════════════════
    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) => {
        // 优先按结构化 JSON 渲染成报告页(含截断自动修复);模型未遵守约定时回退 Markdown
        const jc = extractAiJson(ans);
        if (jc && (jc.data.verdict || Array.isArray(jc.data.findings) || Array.isArray(jc.data.missed_threats))) {
            renderAiReport(el, jc.data, jc.repaired);
            return;
        }
        try {
            el.innerHTML = (typeof marked !== 'undefined' && marked.parse)
                ? marked.parse(ans)
                : ans.replace(/\n/g, '
'); } catch(e) { el.textContent = ans; } }; // ── AI 结构化回答处理(后端:JSON → 页面) ──────────────────────────────── // 从 AI 回复中提取 JSON(容忍 ```json 围栏、前后杂讯与「输出被截断」的残缺 JSON) // 返回 { data, repaired } 或 null;repaired=true 表示经过截断修复,结果可能不完整 const tryJson = (s) => { try { return JSON.parse(s); } catch (e) { return null; } }; // 状态扫描:未闭合括号(按序)、是否停在字符串内、最后一个「字符串外」闭合括号位置 const scanJson = (s) => { const st = []; let inStr = false, esc = false, lastClose = -1; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (esc) { esc = false; continue; } if (ch === '\\') { esc = true; continue; } if (ch === '"') { inStr = !inStr; continue; } if (inStr) continue; if (ch === '{') st.push('}'); else if (ch === '[') st.push(']'); else if (ch === '}' || ch === ']') { st.pop(); lastClose = i; } } return { st, inStr, lastClose }; }; const extractAiJson = (ans) => { if (!ans) return null; const s = String(ans).replace(/```(?:json)?/gi, ''); const a = s.indexOf('{'); if (a < 0) return null; const b = s.lastIndexOf('}'); // 右边界取最后一个 }:正常情况恰好框住 JSON;截断时则落在最后一个完整元素上 const body = b > a ? s.substring(a, b + 1) : s.substring(a); // 1) 完整 JSON:直接解析 let d = tryJson(body); if (d) return { data: d, repaired: false }; // 2) 截断修复 A:回退到最后一个完整闭合的元素(丢掉残缺尾部,如悬空的 key/半个对象),再补全未闭合括号 // 注意:栈是「底→顶」入栈,闭合须逆序(先关数组/内层,再关对象/外层) const sc = scanJson(body); if (sc.lastClose >= 0) { const cut = body.substring(0, sc.lastClose + 1).replace(/[\s,]+$/, ''); const c = scanJson(cut); if (!c.inStr) { d = tryJson(cut + c.st.reverse().join('')); if (d) return { data: d, repaired: true }; } } // 3) 截断修复 B:补全悬空的字符串引号,去掉尾逗号,再补全未闭合括号 let out = (sc.inStr ? body + '"' : body).replace(/[\s,]+$/, ''); const c2 = scanJson(out); if (c2.inStr) return null; d = tryJson(out + c2.st.reverse().join('')); return d ? { data: d, repaired: true } : null; }; const AI_SEV_COL = { low: '#ffb300', medium: '#fb8c00', high: '#f44336', critical: '#b71c1c' }; const aiJudgeMeta = (j) => { const k = String(j || '').toLowerCase(); if (k.includes('false')) return { label: t('jFalse'), col: '#4caf50' }; if (k.includes('uncertain') || k.includes('unsure')) return { label: t('jUncertain'), col: '#9e9e9e' }; if (k.includes('confirm') || k.includes('true')) return { label: t('jConfirmed'), col: '#f44336' }; return { label: String(j || '—'), col: '#9e9e9e' }; }; const aiVerdictMeta = (v) => { const k = String(v || '').toLowerCase(); if (k.includes('danger')) return { label: t('danger'), col: '#f44336', icon: '⛔' }; if (k.includes('suspic')) return { label: t('suspicious'), col: '#ff9800', icon: '⚠️' }; return { label: t('safe'), col: '#4caf50', icon: '✅' }; }; const aiBadge = (txt, col) => `${esc(txt)}`; const aiSevBadge = (sv) => { const k = String(sv || '').toLowerCase(); return AI_SEV_COL[k] ? aiBadge(k.toUpperCase(), AI_SEV_COL[k]) : ''; }; const aiSection = (title) => `
${title}
`; const aiItem = (name, judgment, severity, reasoning, evidence) => { const jm = aiJudgeMeta(judgment); return `
` + `
${esc(name)}` + aiBadge(jm.label, jm.col) + aiSevBadge(severity) + `
` + (reasoning ? `
${esc(reasoning)}
` : '') + (evidence ? `
${esc(evidence)}
` : '') + `
`; }; // 渲染 AI 结构化报告页:截断提示 → 判定横幅 → 思考层 → 逐条复核 → 漏检威胁 → 域名 → 描述/趣味结论 const renderAiReport = (el, d, truncated) => { const v = aiVerdictMeta(d.verdict); let h = truncated ? `
⚠ ${t('aiTruncated')}
` : ''; h += `
` + `${v.icon}` + `${esc(v.label)}
` + (d.verdict_reason ? `
${esc(d.verdict_reason)}
` : ''); if (d.thinking) { h += `
🧠 ${t('aiThinking')}` + `
${esc(d.thinking)}
`; } if (Array.isArray(d.findings) && d.findings.length) { h += aiSection(`📋 ${t('aiFindings')}`) + d.findings.map(f => aiItem(f.name || f.id || '?', f.judgment, f.severity, f.reasoning, f.evidence)).join(''); } if (Array.isArray(d.missed_threats) && d.missed_threats.length) { h += aiSection(`🕵️ ${t('aiMissed')}`) + d.missed_threats.map(f => aiItem(f.name || '?', 'confirmed', f.severity, f.reasoning, f.evidence)).join(''); } if (Array.isArray(d.domains) && d.domains.length) { h += aiSection(`🌐 ${t('aiDomains')}`) + d.domains.map(dm => { const k = String(dm.risk || '').toLowerCase(); const col = k.includes('malic') ? '#f44336' : k.includes('suspic') ? '#ff9800' : '#4caf50'; const lbl = k.includes('malic') ? t('danger') : k.includes('suspic') ? t('suspicious') : t('safe'); return `
` + `
${esc(dm.domain || '?')}` + aiBadge(lbl, col) + `
` + (dm.reasoning ? `
${esc(dm.reasoning)}
` : '') + `
`; }).join(''); } if (d.description) h += aiSection(`📖 ${t('aiDesc')}`) + `
${esc(d.description)}
`; if (d.humor) h += aiSection(`😄 ${t('aiHumor')}`) + `
${esc(d.humor)}
`; el.innerHTML = h; }; // ── 权限使用分析:披露 @grant 声明、明文实际调用、以及仅在混淆/编码载荷中的用法 ── // 与 scanCode() 共用 decodeObfuscation() 解码能力;parseMetadata() 在运行时已就绪。 const analysePermissions = (code) => { // 头部声明的 @grant(从 ==UserScript== 元数据解析) const declared = (parseMetadata(code)['grant'] || []) .map(g => g.trim()).filter(Boolean); // 权限 API 令牌:GM.* / GM_* / unsafeWindow const tokenRe = /(GM\.[A-Za-z_$]+|GM_[A-Za-z_$]+|unsafeWindow)/g; const scanIn = (src) => { const out = {}; if (!src) return out; const re = new RegExp(tokenRe.source, 'g'); let m; while ((m = re.exec(src)) !== null) { // 若 m.index 处实为 "GM_" 开头但捕获不足,兜底用整个匹配 const tk = m[1] || m[0]; out[tk] = out[tk] || { count: 0, lines: [] }; out[tk].count++; const ln = src.slice(0, m.index).split('\n').length; if (!out[tk].lines.includes(ln)) out[tk].lines.push(ln); if (re.lastIndex === m.index) re.lastIndex++; } return out; }; // 仅对脚本正文统计"实际调用",剔除头部 ==UserScript== 声明区(避免 @grant 行被误计为使用) const body = code.replace(/\/\/\s*==UserScript==[\s\S]*?\/\/\s*==\/UserScript==/, ''); const visible = scanIn(body); // 明文中实际出现的调用 const decoded = decodeObfuscation(code); // 编码/混淆载荷还原 const hidden = decodeObfuscation(body); // 正文部分单独还原,供行号与负载披露 const hiddenScan = scanIn(hidden || decoded); // 汇总"实际使用"(用于对比声明) const used = {}; Object.keys(visible).forEach(tk => { used[tk] = { ...visible[tk], lines: [...visible[tk].lines] }; }); // 仅在解码/混淆载荷中才出现、或载荷中调用次数多于明文的 → 标记为"混淆使用" const obfuscated = []; Object.keys(hiddenScan).forEach(tk => { const hc = hiddenScan[tk].count; const vc = (visible[tk] || { count: 0 }).count; if (hc > vc) { obfuscated.push(tk); if (!used[tk]) used[tk] = { count: hc, lines: [...hiddenScan[tk].lines], onlyObfuscated: true }; else used[tk].onlyObfuscated = true; } }); return { declared, used, obfuscated, decodedText: (hidden || decoded || null) }; }; // ── AI 提示词(结构化复核版,v1.3.0) ──────────────────────────────────── // 目标:让 AI 逐条复核本地扫描的每个危险点是否合理(真威胁 / 误报 / 存疑), // 并以严格 JSON(含 thinking 思考层)返回,由 renderAiReport 解析渲染成结构化页面。 const buildPrompt = (findings, net, code, langName, describe, depResults) => { const risk = computeRisk(findings, net); const LANG = langName.toUpperCase(); // 本地命中项编号(F1..Fn),供 AI 逐条引用复核 const findingLines = findings.length ? findings.map((x, i) => `[F${i + 1}] ${x.en} — ${x.den}` + ` | local_severity=${x.v}/4 | hits=${x.count}` + (x.decoded ? ' | found_in=DECODED_PAYLOAD' : '') + (x.lines.length ? `\n lines: ${x.lines.join(' ; ')}` : '') ).join('\n') : '(none — the static scanner found nothing)'; const depSummary = depResults && depResults.length ? '\n=== DEPENDENCIES (external scripts via @require, pre-scanned) ===\n' + depResults.map(d => `${d.url}: ${d.findings.length ? d.findings.map(x => `${x.en}(x${x.count})`).join(', ') : 'clean'}`).join('\n') + '\n' : ''; const domainSummary = net.d.length ? net.d.join(', ') : '(none)'; // ── 权限使用概要:披露 @grant 声明、明文实际调用、以及仅在混淆/编码载荷中出现的用法 ── const perm = analysePermissions(code); const permRows = []; const usedToks = Object.keys(perm.used); const fmtPerm = (tk) => { const u = perm.used[tk]; return `${tk} ×${u.count}${u.onlyObfuscated ? ' [OBFUSCATED/ENCODED-only]' : ''} (lines: ${u.lines.slice(0, 8).map(n => 'L' + n).join(', ')})`; }; if (perm.declared.length) { const declaredSet = new Set(perm.declared); const declaredUsed = perm.declared.filter(d => perm.used[d]); const declaredUnused = perm.declared.filter(d => !perm.used[d]); const usedNotDeclared = usedToks.filter(tk => !declaredSet.has(tk)); permRows.push(`DECLARED @grant (${perm.declared.length}): ${perm.declared.join(', ')}`); if (declaredUsed.length) permRows.push(`Actually USED in code: ` + declaredUsed.map(fmtPerm).join(' ; ')); if (declaredUnused.length) permRows.push(`DECLARED but NOT actually called: ${declaredUnused.join(', ')}`); if (usedNotDeclared.length) permRows.push(`USED but NOT declared in @grant: ` + usedNotDeclared.map(fmtPerm).join(' ; ')); } else { permRows.push(`No ==UserScript== @grant header parsed (manual pastes need the // ==UserScript== heading).`); if (usedToks.length) permRows.push(`Sensitive APIs referenced in code: ` + usedToks.map(fmtPerm).join(' ; ')); } if (perm.obfuscated.length) { permRows.push(`==> OBFUSCATED/ENCODED PERMISSION USAGE — these sensitive APIs only materialise AFTER decoding the encoded payload (strong danger signal): ${perm.obfuscated.join(', ')}`); permRows.push(`Decoded payload (truncated):\n${(perm.decodedText || '').substring(0, 4000)}`); } const permBlock = permRows.join('\n'); return `YOU ARE A SENIOR JAVASCRIPT SECURITY AUDITOR SPECIALISING IN USERSCRIPTS (Tampermonkey/Greasemonkey, e.g. from Greasyfork). === MISSION === A static regex scanner has flagged the userscript below. Regex rules cannot understand context, so they often produce false positives (e.g. fetch() to a public API is normal; atob() decoding icon data is common). Your job: 1. Judge EVERY flagged finding [F1..Fn] IN THE CONTEXT of this specific script: is it a real, present risk (confirmed), a false positive, or uncertain? 2. Hunt for malicious behaviour the static scanner MISSED. 3. Assess every external domain found in the code. 4. Give a final verdict. === LOCAL STATIC SCAN === RISK SCORE: ${risk.score} (level: ${risk.level.toUpperCase()}) FLAGGED FINDINGS (your review queue — answer for each id, keep order): ${findingLines} === EXTERNAL DOMAINS FOUND === ${domainSummary} ${depSummary} === PERMISSION USAGE (declared @grant vs ACTUAL usage vs encoded) === ${permBlock} === FULL SCRIPT CODE (truncated to ${MAX_CODE_LEN} chars) === ${code.substring(0, MAX_CODE_LEN)} === OUTPUT CONTRACT — READ CAREFULLY === Respond with ONE JSON object and NOTHING ELSE: no markdown fences, no text before or after, no comments inside JSON. Keep JSON keys and enum values exactly as specified below; ALL human-readable text values MUST be written in ${LANG} only. { "thinking": "…", "verdict": "SAFE" | "SUSPICIOUS" | "DANGEROUS", "verdict_reason": "…", "findings": [ { "id": "F1", "name": "…", "judgment": "confirmed" | "false_positive" | "uncertain", "severity": "low" | "medium" | "high" | "critical", "reasoning": "…", "evidence": "…" } ], "missed_threats": [ { "name": "…", "severity": "low" | "medium" | "high" | "critical", "reasoning": "…", "evidence": "…" } ], "domains": [ { "domain": "…", "risk": "benign" | "suspicious" | "malicious", "reasoning": "…" } ]${describe ? `, "description": "…", "humor": "…"` : ''} } FIELD MEANINGS: - "thinking" (REQUIRED — your thinking layer): 4-10 sentences. Reason step by step BEFORE concluding: what the script appears to do → its data flows (what it reads, where it sends) → each flagged finding in context → missed threats → verdict. Be candid. - "verdict_reason": 1-3 sentences justifying the verdict. - "findings": EXACTLY one entry per flagged id above, same order, same id. "name" = the flagged API/pattern; "judgment" = your ruling; "severity" = actual risk in THIS script (for false_positive always use "low"); "reasoning" = 1-3 sentences why; "evidence" = line numbers / quoted snippet. - "missed_threats": threats you found that the scanner did not flag; [] if none. - "domains": one entry per external domain above ([] if none); "risk" = your assessment; "reasoning" = why.${describe ? ` - "description": brief, clear summary of what this script does and its main algorithm/workflow. - "humor": humorous conclusion about the script's safety from a user-data-privacy perspective — funny and entertaining, but informative and grounded in the actual findings.` : ''} HARD RULES: - If the scanner flagged nothing, "findings" must be []. - Never invent finding ids; never drop or merge any. - Quote real code/line numbers as evidence — do not guess. - A @grant directive (e.g. "// @grant GM_xmlhttpRequest") only DECLARES a permission — it is NOT a threat by itself. Judge what the API is actually used FOR in the code, and cite the usage line, not the header. Compare declared permissions against the actual usage reported in === PERMISSION USAGE === (declared-but-unused, used-but-not-declared, and especially OBFUSCATED/ENCODED usage). - A sensitive permission (GM_xmlhttpRequest, GM.cookie, unsafeWindow, GM.get/setValue, etc.) that only materialises inside an encoded/obfuscated payload is a strong danger signal — the script author intentionally hid that particular privileged call. Weigh it heavily even if the plain-text code looks harmless. - Requests to well-known public APIs for the script's stated functionality are normal; assess risk by destination domain and what data is sent. - The output must parse with JSON.parse() directly.`; }; // ══════════════════════════════════════════════════════════════════════════ // PUTER.JS —— 无需 API 密钥的 Llama // ══════════════════════════════════════════════════════════════════════════ 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'); //用于加载第三方 Puter.js(提供免 API 密钥的 Llama AI 能力) 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('无法从 js.puter.com 加载 Puter.js')); (document.head || document.documentElement).appendChild(s); }); const callPuter = async (promptText) => { await loadPuter(); if (!RW.puter?.ai?.chat) throw new Error('加载 Puter.js 后 puter.ai.chat 仍不可用。'); 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; // ══════════════════════════════════════════════════════════════════════════ // 主函数:扫描 + 渲染小组件 // ══════════════════════════════════════════════════════════════════════════ // 预取 .user.js 下载链接的完整内容(异步)。 // 权限最小化:优先用页面上下文 fetch(同源成功则无需 @connect); // 跨域或被 CORS 拦截时回退到 GM XHR(仅对 @connect 白名单中的主流仓库域名生效)。 const fetchUserJs = (url) => new Promise(resolve => { const doGmXhr = () => gmXhr({ method:'GET', url, onload: r => resolve(r.responseText || ''), onerror: () => resolve(null) }); if (typeof fetch === 'function') { fetch(url).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }).then(t => resolve(t || '')) .catch(() => doGmXhr()); // 跨域/CORS → 回退 GM XHR } else { doGmXhr(); } }); // 在详情页中查找以 .user.js 结尾的下载链接,返回其绝对地址。 // 若页面存在多个安装链接,则以鼠标悬浮/触摸长按所指的链接为准;否则回退到第一个。 const activeLinkEl = { el: null }; // 当前用户悬浮/长按的安装链接 const isUserJsLink = (a) => !!a && a.tagName === 'A' && /\.user\.js($|[?#])/i.test(a.getAttribute('href') || ''); const getUserJsUrl = () => { const sel = 'a[href$=".user.js"], a[href*=".user.js?"], a[href*=".user.js#"]'; const a = (activeLinkEl.el && isUserJsLink(activeLinkEl.el)) ? activeLinkEl.el : document.querySelector(sel); if (!a) return null; try { return new URL(a.getAttribute('href'), location.href).href; } catch(e) { return null; } }; // 为用户选择安装链接提供依据:悬浮鼠标 / 触摸长按均可指定目标链接 const bindActiveLinkDetection = () => { if (bindActiveLinkDetection.done) return; bindActiveLinkDetection.done = true; let _scanTimer = null; const rescan = () => { clearTimeout(_scanTimer); _scanTimer = setTimeout(() => scan(), 300); }; document.addEventListener('mouseover', (e) => { const a = e.target && e.target.closest ? e.target.closest('a') : null; if (isUserJsLink(a) && a !== activeLinkEl.el) { activeLinkEl.el = a; rescan(); } }); document.addEventListener('mouseout', (e) => { const a = e.target && e.target.closest ? e.target.closest('a') : null; if (a && a === activeLinkEl.el) activeLinkEl.el = null; }); // 触摸长按(约 500ms)后同样指定为可选链接 let _t = null; document.addEventListener('touchstart', (e) => { const a = e.changedTouches && e.changedTouches[0] && e.changedTouches[0].target ? e.changedTouches[0].target.closest('a') : null; if (!isUserJsLink(a)) return; _t = setTimeout(() => { activeLinkEl.el = a; rescan(); }, 500); }, { passive: true }); document.addEventListener('touchmove', () => { if (_t) { clearTimeout(_t); _t = null; } }, { passive: true }); document.addEventListener('touchend', () => { if (_t) { clearTimeout(_t); _t = null; } }, { passive: true }); }; // 从脚本源码中解析顶部「==UserScript==」注释块的元数据(@key value 键值对) const parseMetadata = (js) => { const md = {}; const m = js.match(/\/\/\s*==UserScript==[^\n]*\r?\n([\s\S]*?)\r?\n\s*\/\/\s*==\/UserScript==/); if (m) { m[1].split(/\r?\n/).forEach(l => { const kv = l.match(/^\s*\/\/\s*@([\w:]+)\s*(.*)$/); if (kv) { const key = kv[1], val = kv[2].trim(); (md[key] = md[key] || []).push(val); } }); } return md; }; // 缓存已预取内容,避免重复网络请求 let jsCache = { url: null, text: '' }; let manualCode = null; // 「手动输入脚本」菜单填入的待分析源码 const scan = async () => { document.getElementById('gfs')?.remove(); let code = '', meta = {}, srcUrl = ''; // 优先:用户通过脚本菜单「分析脚本」手动粘贴的源码 if (manualCode) { code = manualCode; manualCode = null; meta = parseMetadata(code); srcUrl = t('manualSrc'); } // 其次:预取详情页中的 .user.js 下载链接内容 const dlUrl = getUserJsUrl(); if (dlUrl) { if (jsCache.url === dlUrl) code = jsCache.text; else { const js = await fetchUserJs(dlUrl); if (js) { code = js; jsCache = { url: dlUrl, text: js }; } } if (code) { meta = parseMetadata(code); srcUrl = dlUrl; } } // 后备:读取页面内
 代码块(如 GreasyFork code 页)
        if (!code) {
            const blk = document.querySelector('pre code') || document.querySelector('pre');
            if (blk) code = blk.innerText || blk.textContent || '';
        }
        if (!code) return false;

        // ── 本地扫描(主脚本) ───────────────────────────────────────────────
        const mainRes = scanCode(code);
        const findings = mainRes.findings;
        let maxV = mainRes.maxV;
        const net = mainRes.net;

        // ── 依赖扫描:抓取并扫描脚本 @require 引入的外部脚本 ─────────────────
        const depResults = [];
        const depAll = Object.entries(meta)
            .filter(([k]) => k.toLowerCase().startsWith('require'))
            .map(([, v]) => v).flat()
            .filter(u => u && /^https?:/i.test(u.trim()));
        for (const u of depAll) {
            const depCode = await fetchUserJs(u.trim());
            if (!depCode) continue;
            const d = scanCode(depCode);
            depResults.push({ url: u.trim(), maxV: d.maxV, findings: d.findings, net: d.net });
            // 合并依赖中发现的高危等级与域名/URL
            if (d.maxV > maxV) maxV = d.maxV;
            d.net.d.forEach(x => { if (!net.d.includes(x)) net.d.push(x); });
            d.net.u.forEach(x => { if (!net.u.includes(x)) net.u.push(x); });
        }

        // ── 评估风险等级(主脚本 + 依赖合并加权评分) ────────────────────────
        const allFindings = [...findings, ...depResults.flatMap(d => d.findings)];
        const risk = computeRisk(allFindings, net);
        const col = risk.col;
        const status = risk.status;
        maxV = risk.maxV;

        // ══════════════════════════════════════════════════════════════════════
        //  构建三个悬浮窗组件
        // ══════════════════════════════════════════════════════════════════════

        // 从元数据中挑选脚本名 / 版本 / 描述
        const pickMeta = (keys) => keys.map(k => meta[k] && meta[k][0]).find(Boolean) || null;
        const pname = pickMeta(['name:zh','name:en','name','name:ru']);
        const pver  = pickMeta(['version']);
        const pdesc = pickMeta(['description:zh','description:en','description','description:ru']);

        // ── 窗口 1:状态摘要(始终显示,右下角) ─────────────────────────────
        const stPanel = createPanel({ id: 'status', width: 300, icon: '🛡️', title: '脚本安全扫描' });
        stPanel.body.innerHTML = `
            
${status}

${t('issues')}: ${findings.length + depResults.reduce((s,d)=>s+d.findings.length,0)}  |  ${t('domains')}: ${net.d.length}

${pname ? `
📦 ${esc(pname)}${pver ? ` v${esc(pver)}` : ''}
` : ''} ${CLEAR_ALL_API_KEYS ? `

⚠ ${t('keysCleared')}

` : ''}
`; stPanel.show(); // ── 窗口 2:威胁详情(默认隐藏) ───────────────────────────────────── const dtPanel = createPanel({ id: 'details', width: 400, icon: '📋', title: t('report'), visible: false, x: 40, y: Math.max(40, window.innerHeight - 560) }); const details = []; if (Object.keys(meta).length) { const shown = new Set(['name','name:zh','name:en','name:ru','version','description','description:zh','description:en','description:ru']); const rest = Object.entries(meta).filter(([k]) => !shown.has(k)).slice(0, 8); details.push(`
`); details.push(`
📦 ${esc(pname || 'UserScript')}${pver ? ` v${esc(pver)}` : ''}
`); if (pdesc) details.push(`
${esc(pdesc)}
`); if (rest.length) { details.push(`
@ 头部元数据`); details.push(`
${rest.map(([k, v]) => `@${esc(k)} ${esc(v.join(' '))}`).join('
')}
`); } details.push(`
`); } if (net.d.length) { details.push(`
`); details.push(`
🌐 ${t('domains')} (${net.d.length})
`); details.push(`
${net.d.map(esc).join(', ')}
`); if (net.u.length <= 20) { details.push(`
${t('showUrls')} (${net.u.length})`); details.push(`
${net.u.map(esc).join('
')}
`); } details.push(`
`); } if (findings.length) { findings.forEach(x => { const fc = x.v >= 4 ? '#f44336' : x.v === 3 ? '#ff9800' : '#ffd700'; const nm = L === 'ru' ? x.ru : L === 'zh' ? x.zh : x.en; const ds = L === 'ru' ? x.dru : L === 'zh' ? x.dzh : x.den; details.push(`
`); details.push(`
⚠ ${esc(nm)} (×${x.count})${x.decoded ? `${L === 'ru' ? 'декод.' : L === 'en' ? 'decoded' : '解码载荷'}` : ''}
`); details.push(`
${esc(ds)}
`); if (x.lines.length) { details.push(`
${t('showLines')}`); details.push(`
${x.lines.map(esc).join('
')}
`); } details.push(`
`); }); } else if (!net.d.length) { details.push(`
✅ ${t('noThreats')}
`); } // ── 依赖扫描结果 ───────────────────────────────────────────────────── if (depResults.length) { depResults.forEach(d => { const depCol = d.maxV >= 3 ? '#f44336' : d.maxV === 2 ? '#ff9800' : d.maxV === 1 ? '#ffd700' : '#4caf50'; details.push(`
`); details.push(`
📦 ${L === 'zh' ? '依赖' : L === 'ru' ? 'Зависимость' : 'Dependency'} ${esc(d.url)}
`); if (d.findings.length) { d.findings.forEach(x => { const fc = x.v >= 4 ? '#f44336' : x.v === 3 ? '#ff9800' : '#ffd700'; const nm = L === 'ru' ? x.ru : L === 'zh' ? x.zh : x.en; const ds = L === 'ru' ? x.dru : L === 'zh' ? x.dzh : x.den; details.push(`
⚠ ${esc(nm)} (×${x.count}) — ${esc(ds)}
`); }); } else { details.push(`
✅ ${L === 'zh' ? '未发现威胁' : L === 'ru' ? 'Угроз не обнаружено' : 'No threats found'}
`); } if (d.net.d.length) { details.push(`
🌐 ${t('domains')}: ${d.net.d.map(esc).join(', ')}
`); } details.push(`
`); }); } dtPanel.body.innerHTML = details.join(''); // ── 窗口 3:AI 分析(默认隐藏) ────────────────────────────────────── const aiPanel = createPanel({ id: 'ai', width: 440, icon: '🤖', title: t('checkAi'), visible: false, x: Math.max(360, window.innerWidth - 480), y: Math.max(40, window.innerHeight - 600) }); let mopts = ''; Object.keys(MODELS).forEach(k => { mopts += ``; }); let lopts = ''; Object.keys(LANGS).forEach(k => { lopts += ``; }); // 清理上次扫描遗留的全屏放大节点(重扫会重建面板内容) document.querySelector('#gfr.gfs-expanded')?.remove(); document.querySelector('.gfs-zoom-restore')?.remove(); aiPanel.body.innerHTML = `
${t('getKey')} ↗
🔧 ${esc(t('customCfg'))}
`; // ── 事件处理 ───────────────────────────────────────────────────────── // 自定义 API:提示用户把 API 域名加入脚本头部的 @connect(否则 GM XHR 会被拒绝) const domainOf = (url) => { try { return new URL(url).hostname; } catch(e) { return ''; } }; const updateCustomHint = () => { const hint = document.getElementById('gfs-custom-hint'); if (!hint) return; const d = domainOf(CURL); hint.textContent = d ? t('customConnect').replace('{d}', d) : t('customConnectNone'); hint.style.display = S === 'custom' ? 'flex' : 'none'; }; updateCustomHint(); // 打开「威胁详情」「AI 分析」入口 document.getElementById('gfs-open-details')?.addEventListener('click', () => dtPanel.show()); document.getElementById('gfs-open-ai')?.addEventListener('click', () => aiPanel.show()); // ── AI 结果放大查看:⛶ 切换全屏覆盖层(Esc / ⤡ 还原) ───────────────── // 面板的 backdrop-filter 会成为 fixed 子元素的包含块, // 放大时把 #gfr 移到 下再 fixed 定位,还原时放回原位 const gfrEl = document.getElementById('gfr'); const zoomBtn = document.getElementById('gfs-zoom'); let gfrHome = null, gfrRestore = null; const setExpanded = (on) => { if (!gfrEl) return; if (on) { if (gfrEl.classList.contains('gfs-expanded')) return; gfrHome = { parent: gfrEl.parentNode, next: gfrEl.nextSibling }; gfrEl.classList.add('gfs-expanded'); document.body.appendChild(gfrEl); // 覆盖层右上角的还原按钮(覆盖层会遮住面板内的 ⛶ 按钮) gfrRestore = document.createElement('button'); gfrRestore.className = 'gfs-btn gfs-zoom-restore'; gfrRestore.textContent = `⤡ ${t('zoomOut')}`; gfrRestore.addEventListener('click', () => setExpanded(false)); document.body.appendChild(gfrRestore); if (zoomBtn) zoomBtn.textContent = '⤡'; } else { gfrEl.classList.remove('gfs-expanded'); if (gfrHome) { gfrHome.parent.insertBefore(gfrEl, gfrHome.next); gfrHome = null; } gfrRestore?.remove(); gfrRestore = null; if (zoomBtn) zoomBtn.textContent = '⛶'; } }; zoomBtn?.addEventListener('click', () => { if (!gfrEl || gfrEl.style.display === 'none') return; // 结果未展示时不放大 setExpanded(!gfrEl.classList.contains('gfs-expanded')); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && gfrEl?.classList.contains('gfs-expanded')) setExpanded(false); }); document.getElementById('gfs-sel-model').addEventListener('change', async e => { S = e.target.value; await gmSet('s', S); const customCfg = document.getElementById('gfs-custom-cfg'); if (customCfg) customCfg.style.display = S === 'custom' ? 'flex' : 'none'; updateCustomHint(); // 切换模型时刷新 Token 输入框的显示状态、值、链接与 保存/更新 按钮 const ktRow = document.getElementById('gfs-kt-row'); if (ktRow) ktRow.style.display = MODELS[S].noKey ? 'none' : 'flex'; const kt = document.getElementById('gfs-kt'); if (kt) kt.value = ''; refreshKeyUi(); }); // 自定义 API:地址 & 模型名 —— 输入后即时保存 document.getElementById('gfs-cu')?.addEventListener('change', async e => { CURL = e.target.value.trim(); await gmSet('cu', CURL); updateCustomHint(); }); document.getElementById('gfs-cm')?.addEventListener('change', async e => { CMODEL = e.target.value.trim(); await gmSet('cm', CMODEL); }); // Token 输入/保存/链接 —— 统一刷新 UI 与保存逻辑 // 未存密钥显示「保存token」,已存储显示「更新token」;保存后立即清空输入框明文, // 避免 Token 常驻 DOM 被其他脚本读取 const refreshKeyUi = () => { const kt = document.getElementById('gfs-kt'); if (kt) kt.placeholder = keyInputPlaceholder(S); const saveBtn = document.getElementById('gfs-kt-save'); if (saveBtn) saveBtn.textContent = keyForModel(S) ? t('keyBtnUpdate') : t('keyBtnSave'); const link = document.getElementById('gfs-kt-link'); if (link) { const ku = keyUrlOf(S); link.style.display = ku ? 'inline' : 'none'; link.href = ku || '#'; link.title = ku; } }; const saveKey = async () => { const kt = document.getElementById('gfs-kt'); if (!kt) return; const v = kt.value.trim(); if (S === 'custom') { if (v) K.custom = v; else delete K.custom; } else { const slot = keySlotOf(S); if (v) K[slot] = v; else delete K[slot]; } await gmSet('k', K); kt.value = ''; refreshKeyUi(); }; document.getElementById('gfs-kt-save').addEventListener('click', saveKey); document.getElementById('gfs-kt')?.addEventListener('change', saveKey); refreshKeyUi(); 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, depResults); 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; } // ── 分支 C:自定义 API —— 获取地址 & 模型名 ────────────────────── if (cfg.custom) { let customUrl = CURL; if (!customUrl) { customUrl = window.prompt(`${t('customCfg')}\n${t('customUrl')}:\n${t('customToken')}`, 'http://127.0.0.1:8000/v1/chat/completions'); if (!customUrl || !customUrl.trim()) { res.textContent = t('noKey'); btn.disabled = false; btn.textContent = `🤖 ${t('checkAi')}`; aiInProgress = false; return; } CURL = customUrl.trim(); await gmSet('cu', CURL); } let customModel = CMODEL || cfg.m; if (!customModel) { customModel = window.prompt(`${t('customModel')}:`, ''); if (!customModel || !customModel.trim()) { res.textContent = t('noKey'); btn.disabled = false; btn.textContent = `🤖 ${t('checkAi')}`; aiInProgress = false; return; } CMODEL = customModel.trim(); await gmSet('cm', CMODEL); } cfg.u = CURL; cfg.m = CMODEL; } // ── 分支 B:使用 API 密钥的模型 (GM XHR) ───────────────────────── const keySlot = cfg.sharedKey || S; let key = K[keySlot]; if (cfg.custom) { // 自有 API 可能无需密钥,为空则跳过鉴权头 key = K.custom || ''; } else if (!key) { // 不弹窗索取:请用户先在面板的 Token 输入框中填写并点「保存token」 res.textContent = `${t('noKey')} · ${t('keyBtnSave')}`; 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 }] }], generationConfig: { maxOutputTokens: 8192, temperature: 0.2 } }); } else if (cfg.claude) { // Anthropic 使用独立的 Messages API:x-api-key 鉴权,响应取 content[0].text headers['x-api-key'] = key; headers['anthropic-version'] = '2023-06-01'; body = JSON.stringify({ model: cfg.m, max_tokens: 8192, temperature: 0.2, system: `You are a senior userscript security auditor. Respond with ONE strict JSON object exactly as specified in the user's OUTPUT CONTRACT — no markdown fences, no extra text. All human-readable values in ${langName}.`, messages: [ { role: 'user', content: promptText } ] }); } else { const reqBody = { model: cfg.m, messages: [ { role: 'system', content: `You are a senior userscript security auditor. Respond with ONE strict JSON object exactly as specified in the user's OUTPUT CONTRACT — no markdown fences, no extra text. All human-readable values in ${langName}.` }, { role: 'user', content: promptText } ], temperature: 0.2, max_tokens: 8192 }; if (cfg.orModel) reqBody.tool_choice = 'none'; body = JSON.stringify(reqBody); if (key) 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}` : '') : (cfg.claude ? j.content?.[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: () => { // 自定义 API 跨域被拒时(域名未加入 @connect)给出明确提示 const hint = cfg.custom && domainOf(CURL) ? `\n${t('customConnect').replace('{d}', domainOf(CURL))}` : ''; res.textContent = t('netErr') + hint; btn.disabled = false; btn.textContent = `🤖 ${t('checkAi')}`; aiInProgress = false; } }); }); // 预取分析完成 → 在该 .user.js 下载链接上打风险标记(进入安装器前已处理) // 仅当来源是真实 URL 时才标记页面链接,手动粘贴的脚本不作用于页面 if (srcUrl && /^https?:/i.test(srcUrl)) { const dl = document.querySelector('a[href$=".user.js"], a[href*=".user.js?"], a[href*=".user.js#"]'); if (dl) { dl.style.outline = '2px solid ' + col; dl.style.outlineOffset = '2px'; dl.style.borderRadius = '4px'; dl.title = t('report') + ': ' + status; } } return true; }; // ══════════════════════════════════════════════════════════════════════════ // 使用 MutationObserver 进行初始化 // ══════════════════════════════════════════════════════════════════════════ let _observer = null; const cleanup = () => { if (_observer) { _observer.disconnect(); _observer = null; } // 清理所有悬浮窗及其全局入口按钮 Object.values(panels).forEach(p => p.destroy()); document.getElementById('gfs-fab')?.remove(); _fab = null; }; // ══════════════════════════════════════════════════════════════════════════ // 脚本菜单:「手动输入脚本并分析」 // 在油猴菜单注册入口,弹出一个可粘贴脚本源码的悬浮窗;调用 scan() 复用完整 // 的本地扫描 + AI 分析管道。仅通过菜单触达,页面无脚本时完全隐藏。 // ══════════════════════════════════════════════════════════════════════════ const openManualPanel = () => { const p = createPanel({ id: 'manual', width: 480, icon: '⌨️', title: t('manualTitle'), visible: false, x: Math.max(40, (window.innerWidth - 480) / 2), y: 80 }); p.body.innerHTML = `
`; p.show(); const ta = document.getElementById('gfs-manual-code'); document.getElementById('gfb-manual-run').addEventListener('click', () => { const code = ta.value; if (!code.trim()) { ta.focus(); return; } manualCode = code; // scan() 读取该值并立即分析(同时重置 manualCode) p.hide(); scan(); }); setTimeout(() => { try { ta.focus(); } catch (e) {} }, 30); }; // 兼容 TamperMonkey(GM_registerMenuCommand)与 Greasy/ViolentMonkey 4+(GM.registerMenuCommand) const registerManualMenu = () => { if (registerManualMenu.done) return; registerManualMenu.done = true; let reg = null; try { if (typeof GM_registerMenuCommand === 'function') reg = GM_registerMenuCommand; else if (typeof GM !== 'undefined' && typeof GM.registerMenuCommand === 'function') reg = GM.registerMenuCommand.bind(GM); } catch (e) { console.warn('[GF-Scanner] 菜单注册失败:', e); } if (reg) reg(t('manualMenu'), openManualPanel); }; const init = () => { cleanup(); registerManualMenu(); bindActiveLinkDetection(); scan().then(done => { if (done) return; watchForContent(); }); }; // 内容(.user.js 链接或
 代码块)尚未出现时,持续等待
    const watchForContent = () => {
        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(() => {
                    scan().then(done => {
                        if (done) {
                            _observer?.disconnect(); _observer = null;
                        } else if (++tries >= MAX_RETRIES) {
                            _observer?.disconnect(); _observer = null;
                            console.warn('[GF-Scanner] 经过', MAX_RETRIES, '次尝试仍未找到脚本内容或 .user.js 链接');
                        }
                    });
                });
            });
            _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(() => {
                scan().then(done => { if (done || ++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());

})();