• SeaMod

    在油猴上安装完插件后,必须点击右上角输入对应数据库的key,大部分都是可以免费注册赠送免费key的

    浏览器输入 https://example.com/#ip-quality 就可以自动打开脚本页面了

    随机输入了个搬瓦工家的cn2线路ip,这是结果:

    图片```
    // ==UserScript==
    // @name IP 质量体检 · 任意 IP · NQ 风格
    // @namespace local.ip-quality.manual
    // @version 2.10.1
    // @description 安装后无需保留本地文件夹;打开 https://example.com/#ip-quality 查询任意 IP。含基础信息、类型、风险评分、风险因子。
    // @match https://example.com/*
    // @run-at document-end
    // @noframes
    // @sandbox DOM
    // @grant GM_xmlhttpRequest
    // @grant GM_getValue
    // @grant GM_setValue
    // @grant GM_deleteValue
    // @connect ipinfo.check.place
    // @connect geoip.maxmind.com
    // @connect geolite.info
    // @connect ipinfo.io
    // @connect api.ipapi.is
    // @connect api.abuseipdb.com
    // @connect api.ip2location.io
    // @connect www.ipqualityscore.com
    // @connect api.ipregistry.co
    // @connect api.db-ip.com
    // @connect api.ipdata.co
    // @connect scamalytics.com
    // @license AGPL-3.0-only
    // ==/UserScript==

    /*
    INSTALL / 安装:
    1. 安装官方 Tampermonkey:https://www.tampermonkey.net/
    2. 在油猴管理面板选择“添加新脚本”,清空模板,粘贴本文件全部内容,Ctrl+S 保存。
    Chrome 如提示,打开 Tampermonkey 详情中的“允许用户脚本”开关。
    3. 打开并收藏 https://example.com/#ip-quality
    4. 确认页面能显示后,本 .user.js 下载文件和旧版解压目录均可删除。
    必须保留 Tampermonkey 扩展及其中已安装的本脚本;旧 Chrome 扩展可移除。
    迁移:旧扩展中保存的 API Key 不会自动导入,需要在油猴版“接口设置”重新填写。
    限制:不含他人的 API Key;公共接口可能返回 403。免费套餐可能缺少风险字段。
    一次查询一个 IPv4/IPv6,保留基础信息、IP 类型、风险评分和风险因子。
    源码与样式全部内置;没有 @require、@resource、本地文件读取或后台自动更新。
    查询仍需联网。首次使用跨域查询时,按油猴提示允许列出的服务域名。
    基础信息字段与评分显示参考 xykt/IPQuality,非原作者发布。
    This derivative work is licensed under AGPL-3.0-only, without warranty.
    Original project: https://github.com/xykt/IPQuality
    原扩展 v1.1 的查询及排版已迁移为 v2.10.1 单文件,修改日期 2026-09-05。
    */

    (async function(){
    'use strict';
    if(location.hostname!=='example.com'||location.pathname!=='/'||location.hash!=='#ip-quality')return;
    if(document.readyState==='loading')await new Promise(resolve=>document.addEventListener('DOMContentLoaded',resolve,{once:true}));
    const markup="<!doctype html>\n<html lang=\"zh-CN\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>IP 质量查询</title><style>:root{color-scheme:dark;--bg:#1b1e25;--panel:#242832;--terminal:#101216;--ink:#ebedf2;--muted:#929aaa;--line:#363d49;--cyan:#55dfe3;--green:#66e579;--red:#ff6969;--yellow:#ffda6a;--mono:Consolas,'Cascadia Mono','Liberation Mono','Microsoft YaHei',monospace}{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:system-ui,'Segoe UI','Microsoft YaHei',sans-serif;font-size:13px;line-height:1.5}main{max-width:1120px;margin:0 auto;padding:24px 24px 16px}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:18px}.brand{display:flex;gap:11px;align-items:center}.brand-symbol{display:grid;place-items:center;width:34px;height:34px;border:1px solid #55dfe35c;border-radius:6px;color:var(--cyan);font:700 17px var(--mono)}h1{font-size:17px;margin:0;font-weight:650;letter-spacing:1px}.brand .muted{font-size:11px}.toolbar-actions{display:flex;gap:8px}button{font:inherit;font-size:12px;font-weight:600;border:1px solid var(--cyan);background:var(--cyan);color:#10272a;padding:8px 14px;border-radius:5px;cursor:pointer;white-space:nowrap}button.secondary{border-color:#46505f;background:transparent;color:#dce3ed}button:hover:enabled{filter:brightness(1.13)}button:disabled{opacity:.4;cursor:default}input,select{font:inherit;border:1px solid #495364;border-radius:4px;background:#1b1f27;color:var(--ink);padding:8px 10px}input[type=checkbox]{accent-color:var(--cyan)}input:focus-visible,select:focus-visible,button:focus-visible,a:focus-visible,summary:focus-visible{outline:2px solid var(--cyan);outline-offset:3px}.query{border:1px solid var(--line);border-radius:7px;background:var(--panel);padding:13px 16px;margin-bottom:20px}.inputrow{display:flex;align-items:center;gap:10px}.inputrow label{font-weight:600;color:#cbd2dc;white-space:nowrap}#ip{flex:1;min-width:100px;font:15px var(--mono);height:36px}.options{display:flex;align-items:center;gap:22px;flex-wrap:wrap;font-size:12px;color:#b5bfce;margin-top:10px}.options select,.filter select{padding:3px 6px;font-size:12px}.run-status{display:flex;align-items:center;gap:20px;margin-top:11px}#status{font-size:11px;color:var(--muted);flex:1}progress{height:3px;width:120px;border:0;accent-color:var(--cyan)}progress::-webkit-progress-bar{background:#3c4652}progress::-webkit-progress-value{background:var(--cyan)}.terminal{background:var(--terminal);padding:22px 28px 12px;border:1px solid #3d4651;border-radius:5px;box-shadow:0 10px 30px #0002;font-family:var(--mono);font-size:13px;line-height:1.55}.report-heading{text-align:center;border-top:2px solid #7a8693;border-bottom:2px solid #7a8693;padding:10px 0 9px;margin-bottom:13px}.report-heading h2{font-size:18px;margin:0 0 3px;font-weight:700;overflow-wrap:anywhere}.report-heading #reportTitle{color:var(--cyan)}.report-heading p{margin:0;font-size:11px;color:#aab2bf}.report-heading a{color:inherit;text-decoration:none}.report-heading a:hover{text-decoration:underline}.report-meta{display:flex;justify-content:center;gap:28px;font-size:11px;color:#b7bec9;margin-top:5px}.report-section{margin:0;padding:0 0 10px}.report-section+.report-section{border-top:1px dashed #505966;padding-top:8px}.sectionhead{display:flex;align-items:baseline;gap:9px;margin-bottom:5px;flex-wrap:wrap}.sectionhead h2{margin:0;font-size:14px;font-weight:700;letter-spacing:1px}.sectionhead .muted,.section-note{font-size:10px;color:var(--muted)}.section-note{margin-left:auto}.facts{display:flex;flex-direction:column;gap:1px}.fact{display:grid;grid-template-columns:110px 1fr;min-height:20px}.fact-label,.matrix tbody th,.matrix thead th:first-child,.score-name,.scope-line>span:first-child,.risk-legend>span:first-child{color:var(--cyan);font-weight:400}.fact-value{color:var(--green);overflow-wrap:anywhere}.unknown{color:#697484}.scroll{overflow-x:auto;scrollbar-width:thin;scrollbar-color:#4a5565 transparent}table{border-collapse:collapse;width:100%;text-align:left;font-size:12px}th,td{padding:7px 8px;border-bottom:1px solid #2a3039;vertical-align:top}th{color:var(--muted);font-weight:400}.matrix{table-layout:fixed;font-family:var(--mono);min-width:680px}.matrix th,.matrix td{padding:2px 4px;text-align:center;vertical-align:middle;border:0;height:23px;white-space:nowrap}.matrix th:first-child{width:95px;text-align:left;padding-left:0}.matrix thead th{color:#c0c7d0;font-size:11px;font-style:italic}.matrix thead th:first-child{font-size:12px;font-style:normal}.type-matrix{max-width:790px}.type-matrix th:first-child{width:95px}.factor-matrix{min-width:900px}.factor-matrix th:first-child{width:78px}.factor-matrix thead th{font-size:10px}.factor-matrix td{font-size:12px}.badge{display:inline-block;color:var(--muted);font-weight:600;font-size:12px;line-height:1.5}.badge.good{color:var(--green)}.badge.bad{color:var(--red)}.badge.warn{color:var(--yellow)}.type-badge{min-width:50px;padding:0 7px;color:#cad1dc;background:#333c49;line-height:19px;border-radius:1px;font-size:12px}.type-badge.good{background:#2e8f44;color:#fff}.type-badge.bad{background:#b43c45;color:#fff}.type-badge.warn{background:#a48422;color:#fff}.country-code{color:#dce1e9}.risk-legend,.score-row{display:grid;grid-template-columns:110px minmax(150px,1fr) 170px;gap:10px;align-items:center}.risk-legend{font-size:11px;margin-bottom:3px}.risk-legend>div{display:grid;grid-template-columns:repeat(5,1fr);text-align:center;color:#bfc7d1}.risk-legend>div span:nth-child(-n+2){color:var(--green)}.risk-legend>div span:nth-child(3){color:var(--yellow)}.risk-legend>div span:nth-child(n+4){color:var(--red)}.scores{display:flex;flex-direction:column;gap:3px}.score-row{min-height:23px}.score-name{font-size:12px}.score-track{height:21px;position:relative}.score-bands{display:flex;position:absolute;inset:3px 0;background:#25313a;overflow:hidden}.band{width:20%}.band-0{background:#276438}.band-1{background:#3d7943}.band-2{background:#8a731f}.band-3{background:#8f353a}.band-4{background:#70272e}.score-marker{position:absolute;top:0;z-index:1;transform:translateX(-50%);background:#e9f2f3;color:#14272b;min-width:34px;padding:0 5px;line-height:21px;height:21px;text-align:center;font-size:12px;font-weight:700;box-shadow:0 0 0 1px #101216}.marker-0{left:10%}.marker-1{left:30%}.marker-2{left:50%}.marker-3{left:70%}.marker-4{left:90%}.no-score{border-bottom:1px solid #252b32}.score-unavailable{font-size:11px;color:#778291;line-height:21px}.score-row>.badge{white-space:nowrap;font-size:11px}.scope-line{font-size:11px;margin-top:6px;display:flex;gap:12px;color:var(--green);overflow-wrap:anywhere}.chips{display:flex;gap:6px 19px;flex-wrap:wrap;margin:6px 0}.chips .badge{font-size:12px}.inline-details{margin-top:8px}.inline-details summary{color:#b1bac6;font-size:11px;cursor:pointer}.inline-details summary::marker{color:#6d7c8c}.inline-details .small{margin:8px 0}.filter{display:block;font-size:11px;margin:8px 0}.report-end{display:flex;justify-content:space-between;gap:15px;color:#808d9d;border-top:2px solid #7a8693;padding-top:8px;margin-top:1px;font-size:10px}.report-end span:last-child{letter-spacing:1px;color:#5c6979}.notice{font-size:11px;color:#a0adba;padding:9px 1px;line-height:1.65}.card{border:1px solid var(--line);border-radius:5px;background:#20252e;padding:11px 15px;margin:8px 0}.card summary{font-size:12px;cursor:pointer;color:#cbd5e2}.card summary .muted{font-size:11px;margin-left:8px}.card p{font-size:12px}.source-row{display:grid;grid-template-columns:130px 80px 1fr;gap:12px;padding:7px 0;border-bottom:1px solid #333b46;font-size:11px}.source-row a{font-family:var(--mono)}.muted{color:var(--muted)}.small{font-size:11px}a{color:var(--cyan)}footer{text-align:center;font-size:10px;color:#7f8a98;margin-top:18px}dialog{width:min(680px,95vw);max-height:90vh;border:1px solid #536071;background:#20252f;border-radius:8px;padding:24px;color:var(--ink)}dialog::backdrop{background:#0009}dialog h2{font-size:18px;margin:0}dialog p{font-size:12px;color:var(--muted)}.keyfield{display:block;margin:15px 0}.keyfield span{display:block;margin-bottom:5px;font-size:12px}.keyfield input{width:100%;font-family:var(--mono);font-size:12px}.keyfield a{font-size:11px;margin-left:8px}.check{display:block;margin:14px 0;font-size:12px}.dialogbuttons{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:22px}@media(max-width:850px){main{padding:14px}.terminal{padding:16px 16px 12px}.toolbar{align-items:flex-start}.toolbar-actions{gap:5px;flex-wrap:wrap;justify-content:flex-end}button{padding:7px 10px}.risk-legend,.score-row{grid-template-columns:95px minmax(110px,1fr) 155px;gap:6px}.matrix{min-width:650px}.section-note{margin-left:0}.report-heading h2{font-size:16px}}@media(max-width:550px){.toolbar{flex-direction:column;gap:12px}.inputrow{flex-wrap:wrap}.inputrow label{width:100%;font-size:11px}#ip{width:100%;flex-basis:calc(100% - 140px)}.options{gap:10px}.report-meta{gap:4px;flex-direction:column}.risk-legend,.score-row{grid-template-columns:85px minmax(85px,1fr) 70px}.score-row>.badge{white-space:normal;line-height:1.2;font-size:10px}.score-marker{font-size:10px;padding:0 2px;min-width:24px}.fact{grid-template-columns:80px 1fr}.terminal{padding:14px 11px}.report-end span:last-child{display:none}.source-row{grid-template-columns:85px 55px 1fr}progress{width:60px}.run-status{gap:8px}.scope-line{font-size:10px}}@media print{body{background:#101216;print-color-adjust:exact;-webkit-print-color-adjust:exact}main{max-width:none;padding:0}.toolbar,.query,.notice,.card,footer{display:none}.terminal{border:0;box-shadow:none}.inline-details{display:none}}\n\n/ Score bars: transparent track with value and level after the actual fill. /\n.score-track{background:transparent;overflow:visible;border:0}.score-fill{height:100%;min-width:0;border-radius:2px}.score-end-label{position:absolute;top:0;margin-left:8px;display:inline-flex;align-items:center;gap:8px;height:21px;white-space:nowrap}.score-value{position:static;font-weight:700;font-size:12px;color:var(--ink);line-height:21px}.score-end-label .badge{font-size:11px}.score-track.qualitative{display:flex;align-items:center;gap:8px}.score-track.no-score{border:0}.factor-matrix{min-width:820px}\n.risk-legend,.score-row{grid-template-columns:110px minmax(100px,1fr) 110px}.risk-legend>div{display:grid;grid-template-columns:repeat(5,1fr);text-align:center}\n@media(max-width:850px){.risk-legend,.score-row{grid-template-columns:95px minmax(85px,1fr) 100px}}\n@media(max-width:550px){.risk-legend,.score-row{grid-template-columns:85px minmax(65px,1fr) 95px}.score-value{font-size:11px}.score-end-label{gap:5px;margin-left:6px}.risk-legend{font-size:10px}}\n\n/ Three equal reference segments; labels are white inside each segment. /\n.risk-legend{margin-bottom:7px}.risk-legend>.risk-reference{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));height:23px;overflow:hidden;border-radius:2px}.risk-reference>div{display:flex;align-items:center;justify-content:space-between;padding:0 5px;white-space:nowrap;min-width:0}.risk-reference>.reference-yellow{justify-content:center}.reference-green{background:#28b85b}.reference-yellow{background:#e5b82e}.reference-red{background:#ef5350}.risk-reference span{color:#fff!important;font-weight:600;text-shadow:0 1px 2px #000b,0 0 2px #0008}.score-fill.band-0,.score-fill.band-1{background:#28b85b}.score-fill.band-2{background:#e5b82e}.score-fill.band-3,.score-fill.band-4{background:#ef5350}\n\n/ NQ uses ANSI 41/42/43. Browser mapping: Windows Terminal Campbell.\n NQ does not define fixed RGB values; terminal themes may render differently. */\n:root{--nq-red:#c50f1f;--nq-green:#13a10e;--nq-yellow:#c19c00}\n.type-badge.good,.reference-green,.score-fill.band-0,.score-fill.band-1{background:var(--nq-green)}\n.type-badge.warn,.reference-yellow,.score-fill.band-2{background:var(--nq-yellow)}\n.type-badge.bad,.reference-red,.score-fill.band-3,.score-fill.band-4{background:var(--nq-red)}\n.risk-reference span{text-shadow:none}\n</style></head>\n<body><main>\n <header class=\"toolbar\"><div class=\"brand\"><span class=\"brand-symbol\">IP</span><div><h1>IP 质量体检</h1><span class=\"muted\">任意 IP 查询 · 终端报告版</span></div></div><div class=\"toolbar-actions\"><button id=\"screenshot\" class=\"secondary\" title=\"下载完整报告 PNG 图片\">截图</button><button id=\"exportJson\" class=\"secondary\" disabled>导出 JSON</button><button id=\"exportCsv\" class=\"secondary\" disabled>导出 CSV</button><button id=\"settingsButton\" class=\"secondary\">接口设置</button></div></header>\n <section class=\"query\"><form id=\"queryForm\"><div class=\"inputrow\"><label for=\"ip\">目标 IP</label><input id=\"ip\" placeholder=\"输入 IPv4 / IPv6 地址\" autocomplete=\"off\" spellcheck=\"false\" required><button id=\"start\" type=\"submit\">开始查询</button><button id=\"stop\" type=\"button\" class=\"secondary\" disabled>停止</button></div></form><div class=\"run-status\"><span id=\"status\" role=\"status\" aria-live=\"polite\">输入公网 IP 后开始查询</span><progress id=\"progress\" max=\"1\" value=\"0\"></progress></div></section>\n <article class=\"terminal\" aria-label=\"IP 质量体检报告\">\n <div class=\"report-heading\"><h2>IP 质量体检报告:<span id=\"reportTitle\">等待查询</span></h2><p><a href=\"https://github.com/xykt/IPQuality\&quot; target=\"_blank\" rel=\"noreferrer\">参考 xykt / IPQuality</a><span class=\"heading-divider\"> · </span>任意 IP 查询版</p><div class=\"report-meta\"><span id=\"reportTime\">检测时间:—</span><span>版本:v2.10.1</span></div></div>\n <section class=\"report-section\"><div class=\"sectionhead\"><h2>一、基础信息</h2><span id=\"basicSource\" class=\"muted\"></span></div><div id=\"basic\" class=\"facts\"></div></section>\n <section class=\"report-section\"><div class=\"sectionhead\"><h2>二、IP 类型</h2></div><div class=\"scroll\"><table class=\"matrix type-matrix\"><thead id=\"typesHead\"></thead><tbody id=\"types\"></tbody></table></div></section>\n <section class=\"report-section\"><div class=\"sectionhead\"><h2>三、风险评分</h2><span class=\"section-note\" title=\"条形按风险分界值分段线性换算;低与中等、中等与高的换算节点为 33%、66% 长度,临界值归入较高等级。\">按风险区间换算 · 悬停查看分界值</span></div><div class=\"risk-legend\"><span>风险等级:</span><div class=\"risk-reference\" aria-label=\"风险等级对照:绿色极低至低,黄色中等,红色高至极高\"><div class=\"reference-green\"><span>极低</span><span>低</span></div><div class=\"reference-yellow\"><span>中等</span></div><div class=\"reference-red\"><span>高</span><span>极高</span></div></div><span></span></div><div id=\"scores\" class=\"scores\"></div><div class=\"scope-line\"><span>ipapi 网段:</span><span id=\"scoreNetwork\">—</span></div></section>\n <section class=\"report-section\"><div class=\"sectionhead\"><h2>四、风险因子</h2><span class=\"section-note\">-:数据库未返回 · 无:该字段未提供</span></div><div class=\"scroll\"><table class=\"matrix factor-matrix\"><thead id=\"factorsHead\"></thead><tbody id=\"factors\"></tbody></table></div></section>\n <div class=\"report-end\"><span id=\"reportFoot\">等待目标 IP · 未查询不代表低风险</span><span>IP QUALITY CHECK</span></div>\n </article>\n <aside id=\"notice\" class=\"notice\">查询会将目标 IP 发送给数据库。报告仅保留在当前页面。</aside>\n <details class=\"card\" id=\"sourceDetails\"><summary>数据源状态 <span id=\"sourceCount\" class=\"muted\">等待查询</span></summary><p class=\"muted small\">公共接口受限时可配置自己的 API。免费套餐不一定包含全部风控字段。</p><div id=\"sources\"></div></details>\n <details class=\"card\"><summary>使用说明与来源</summary><p>油猴单文件版:安装后可删除下载文件。收藏 https://example.com/#ip-quality 打开查询界面;无需保留本地文件夹。代码和已保存的 API 配置由油猴管理,删除此脚本会影响它们。入口网页不接收表单查询;目标 IP 只通过查询接口发给所选数据库。</p><p>这是基于 xykt/IPQuality 的独立查询脚本,不是原作者发布的工具。保留模块 1~4;不做流媒体、端口连接测试,不上传报告。</p><p>原脚本的 MaxMind 与多库公共接口由 Check.Place 提供。本工具不保证它们永久可用。Ipregistry 使用你的 API Key;DB-IP 未填 Key 时只能获得免费基础字段。</p><p>MaxMind 不可用时,基础信息依次回退到 IPinfo、ipapi、DB-IP,界面标明实际来源。地理位置是数据库记录,不是物理位置实测。</p><p>API Key 默认仅用于本次页面会话。勾选保存后存于本机 油猴脚本存储,不同步;不要把配置或含密钥的 API 地址分享给别人。</p><a href=\"https://github.com/xykt/IPQuality\&quot; target=\"_blank\" rel=\"noreferrer\">原项目与源码</a> · <a href=\"https://www.gnu.org/licenses/agpl-3.0.html\&quot; target=\"_blank\" rel=\"noreferrer\">AGPL-3.0 许可证</a></details>\n <footer>IP 质量查询 2.10 · 油猴版 · 按目标 IP 查询 · 开源 AGPL-3.0</footer>\n</main>\n<dialog id=\"settings\"><form id=\"settingsForm\"><h2>接口设置</h2><p>优先使用你填写的 API;留空时尝试原脚本公共接口。Ipregistry 需要自有 Key;DB-IP 免费接口不含完整风控。</p><label class=\"check\"><input type=\"checkbox\" id=\"relay\" checked> 启用原脚本 Check.Place 公共接口</label><div id=\"keyFields\"></div><label class=\"check\"><input type=\"checkbox\" id=\"remember\"> 将 API 配置保存在本机浏览器</label><p class=\"small muted\">关闭页面后,未保存的密钥会消失。导出报告不包含配置和密钥。</p><div class=\"dialogbuttons\"><button id=\"clearKeys\" type=\"button\" class=\"secondary\">清除已保存密钥</button><button id=\"closeSettings\" type=\"button\" class=\"secondary\">取消</button><button type=\"submit\">应用</button></div></form></dialog>\n</body></html>\n";
    const parsed=new DOMParser().parseFromString(markup,'text/html');
    const root=document.importNode(parsed.documentElement,true);
    if(document.documentElement)document.replaceChild(root,document.documentElement);else document.appendChild(root);

    // SPDX-License-Identifier: AGPL-3.0-only
    // Adapted field mappings / risk thresholds from xykt/IPQuality (AGPL-3.0).
    function parseIP(input) {
    const text = String(input).trim();
    if (/^(0|[1-9]\d{0,2})(\.(0|[1-9]\d{0,2})){3}$/.test(text)) {
    const parts = text.split('.').map(Number);
    if (parts.some(n => n > 255)) throw new Error('IPv4 每段必须在 0~255 之间');
    const [a,b] = parts;
    return {ip: parts.join('.'), version: 4, private: a===10 || a===127 || a===0 || a>=224 || (a===172 && b>=16 && b<=31) || (a===192 && b===168) || (a===169 && b===254) || (a===100 && b>=64 && b<=127)};
    }
    if (text.includes(':') && /^[\da-fA-F:.]+$/.test(text)) {
    try {
    const ip = new URL('http://['+text+']/').hostname.slice(1,-1);
    return {ip, version: 6, private: ip==='::' || ip==='::1' || /^(f[cd]|fe[89ab]|ff)/i.test(ip)};
    } catch {}
    }
    throw new Error('请输入单个有效 IPv4 或 IPv6 地址,不含端口、网址或 /24');
    }
    function get(o, path) { return path.split('.').reduce((v,k)=>v?.[k],o) ?? null; }
    function bool(v) { return v===true || v==='true' ? true : v===false || v==='false' ? false : null; }
    function any(...vs) { const bs=vs.map(bool); return bs.includes(true) ? true : bs.every(v=>v===false) ? false : null; }
    function numeric(v) { return v===null || v===undefined || (typeof v==='string' && v.trim()==='') || !['string','number'].includes(typeof v) ? null : Number.isFinite(Number(v)) ? Number(v) : null; }
    // Risk levels follow the user's display thresholds; scores remain provider values.
    function risk(id, value) {
    if (id==='ipapi') {
    const m=typeof value==='string' && value.match(/^([\d.]+)\s*\(([^)]+)\)$/);
    const n=numeric(m?m[1]:value);
    if(n===null || n<0 || n>1)return null;
    return {value:n,text:(n*100).toFixed(2)+'%',level:n<.01?'低':n<.03?'中等':'高',scope:'company.network 网段滥用比例'};
    }
    if(id==='dbip')return typeof value==='string'?{value:null,text:value,level:({low:'低',medium:'中等',high:'高'})[value.toLowerCase()]??value,scope:'数据库威胁等级(无数值评分)'}:null;
    const n=numeric(value);if(n===null || n<0 || n>100)return null;
    const level=id==='ipqs'?(n<75?'低':n<90?'中等':'高'):['scamalytics','abuseipdb','ip2location'].includes(id)?(n<10?'极低':n<20?'低':n<40?'中等':n<60?'高':'极高'):null;
    return level?{value:n,text:String(n),level,scope:id==='abuseipdb'?'Abuse Confidence Score':'数据库评分'}:null;
    }
    // Piecewise interpolation onto the shared risk-level axis.
    function scoreWidth(id,value){
    if(value===null || value===undefined)return null;
    const n=id==='ipapi'?value*100:value;
    if(!Number.isFinite(n)||n<0||n>100)return null;
    const points=id==='ipapi'?[[0,0],[1,33],[3,66],[100,100]]:id==='ipqs'?[[0,0],[75,33],[90,66],[100,100]]:[[0,0],[20,33],[40,66],[100,100]];
    for(let i=1;i<points.length;i++){
    const [x,y]=points[i], [prevX,prevY]=points[i-1];
    if(n<=x)return prevY+(n-prevX)/(x-prevX)*(y-prevY);
    }
    return 100;
    }
    const MAXMIND_SERVICES={
    'geolite-city':{name:'GeoLite City(免费账户)',url:'https://geolite.info/geoip/v2.1/city/'},
    'geolite-country':{name:'GeoLite Country(免费账户)',url:'https://geolite.info/geoip/v2.1/country/'},
    'geoip-city':{name:'GeoIP City Plus(付费/试用)',url:'https://geoip.maxmind.com/geoip/v2.1/city/'},
    'geoip-country':{name:'GeoIP Country(付费/试用)',url:'https://geoip.maxmind.com/geoip/v2.1/country/'},
    'geoip-insights':{name:'GeoIP Insights(付费/试用)',url:'https://geoip.maxmind.com/geoip/v2.1/insights/'}
    };
    let regionNames;
    function regionLabel(code,name){
    const text=String(code??'').trim(), match=text.match(/^\[?([a-z]{2})\]?$/i);
    if(!match)return text||name||null;
    const iso=match[1].toUpperCase();let zh;
    try{regionNames??=new Intl.DisplayNames(['zh-Hans'],{type:'region',fallback:'none'});zh=regionNames.of(iso);}catch{}
    return '['+iso+']'+(zh?' '+zh:'')+(name&&name!==zh&&name!==iso?' · '+name:'');
    }
    const SOURCES=[
    {id:'maxmind',name:'MaxMind',web:'https://www.maxmind.com/'},
    {id:'ipinfo',name:'IPinfo',web:'https://ipinfo.io/'},
    {id:'scamalytics',name:'Scamalytics',web:'https://scamalytics.com/ip'},
    {id:'ipregistry',name:'Ipregistry',web:'https://ipregistry.co/'},
    {id:'ipapi',name:'ipapi.is',web:'https://ipapi.is/'},
    {id:'abuseipdb',name:'AbuseIPDB',web:'https://www.abuseipdb.com/'},
    {id:'ip2location',name:'IP2Location',web:'https://www.ip2location.io/'},
    {id:'dbip',name:'DB-IP',web:'https://db-ip.com/'},
    {id:'ipdata',name:'ipdata',web:'https://ipdata.co/'},
    {id:'ipqs',name:'IPQS',web:'https://www.ipqualityscore.com/free-ip-lookup-proxy-vpn-test'}
    ];
    function normalize(id, raw) {
    const d=id==='ipinfo' ? raw.data??raw : id==='abuseipdb' ? raw.data??raw : raw;
    const p=path=>get(d,path), f={proxy:null,tor:null,vpn:null,server:null,abuser:null,robot:null};
    const o={id,basic:{},usage:null,company:null,country:null,factors:f,score:null,network:null};
    const flags=map=>Object.entries(map).forEach(([key,path])=>f[key]=bool(p(path)));
    const basic=map=>Object.entries(map).forEach(([key,path])=>o.basic[key]=p(path));
    switch(id) {
    case 'maxmind':
    if(d.country||d.registered_country||d.traits||d.location){
    basic({asn:'traits.autonomous_system_number',org:'traits.autonomous_system_organization',city:'city.names.en',region:'subdivisions.0.names.en',country:'country.names.en',countryCode:'country.iso_code',registered:'registered_country.iso_code',continent:'continent.names.en',timezone:'location.time_zone',latitude:'location.latitude',longitude:'location.longitude',accuracy:'location.accuracy_radius',postal:'postal.code'});
    o.basic.city??=p('city.names.zh-CN');o.basic.region??=p('subdivisions.0.names.zh-CN');o.basic.country??=p('country.names.zh-CN');o.country=o.basic.countryCode;break;
    }
    basic({asn:'ASN.AutonomousSystemNumber',org:'ASN.AutonomousSystemOrganization',city:'City.Name',region:'City.Subdivisions.0.Name',country:'City.Country.Name',countryCode:'City.Country.IsoCode',registered:'Country.RegisteredCountry.IsoCode',continent:'City.Continent.Name',timezone:'City.Location.TimeZone',latitude:'City.Latitude',longitude:'City.Longitude',accuracy:'City.AccuracyRadius',postal:'City.PostalCode'});
    o.basic.registered??=p('City.RegisteredCountry.IsoCode');o.basic.country??=p('Country.Name');o.basic.countryCode??=p('Country.IsoCode'); break;
    case 'ipinfo':
    basic({asn:'asn.asn',org:'asn.name',city:'city',region:'region',countryCode:'country',registered:'abuse.country',timezone:'timezone',postal:'postal',coordinates:'loc'});
    o.basic.org??=p('org');o.usage=p('asn.type');o.company=p('company.type');o.country=p('country');o.network=p('asn.route');
    flags({proxy:'privacy.proxy',tor:'privacy.tor',vpn:'privacy.vpn',server:'privacy.hosting'});break;
    case 'ipapi':
    basic({asn:'asn.asn',org:'asn.org',city:'location.city',region:'location.state',country:'location.country',countryCode:'location.country_code',timezone:'location.timezone',latitude:'location.latitude',longitude:'location.longitude',postal:'location.zip'});
    o.usage=p('asn.type');o.company=p('company.type');o.country=p('location.country_code');o.network=p('company.network');o.score=risk(id,p('company.abuser_score'));
    flags({proxy:'is_proxy',tor:'is_tor',vpn:'is_vpn',server:'is_datacenter',abuser:'is_abuser',robot:'is_crawler'});break;
    case 'scamalytics':
    o.score=risk(id,p('scamalytics.scamalytics_score')??p('score'));o.country=p('external_datasources.maxmind_geolite2.ip_country_code');
    flags({proxy:'external_datasources.firehol.is_proxy',tor:'external_datasources.x4bnet.is_tor',vpn:'scamalytics.scamalytics_proxy.is_vpn',server:'scamalytics.scamalytics_proxy.is_datacenter',abuser:'scamalytics.is_blacklisted_external'});
    f.robot=any(p('external_datasources.x4bnet.is_blacklisted_spambot'),p('external_datasources.x4bnet.is_bot_operamini'),p('external_datasources.x4bnet.is_bot_semrush'));break;
    case 'ipregistry':
    o.usage=p('connection.type');o.company=p('company.type');o.country=p('location.country.code');
    flags({proxy:'security.is_proxy',vpn:'security.is_vpn',server:'security.is_cloud_provider',abuser:'security.is_abuser'});f.tor=any(p('security.is_tor'),p('security.is_tor_exit'));break;
    case 'abuseipdb':o.usage=p('usageType');o.country=p('countryCode');o.score=risk(id,p('abuseConfidenceScore'));break;
    case 'ip2location':
    o.usage=p('usage_type');o.company=p('as_info.as_usage_type');o.country=p('country_code');o.score=risk(id,p('fraud_score'));
    f.proxy=any(p('is_proxy'),p('proxy.is_public_proxy'),p('proxy.is_web_proxy'));
    flags({tor:'proxy.is_tor',vpn:'proxy.is_vpn',server:'proxy.is_data_center',abuser:'proxy.is_spammer'});
    f.robot=any(p('proxy.is_web_crawler'),p('proxy.is_scanner'),p('proxy.is_botnet'));break;
    case 'dbip':o.country=p('countryCode');o.score=risk(id,p('threatLevel'));flags({proxy:'isProxy',robot:'isCrawler'});basic({city:'city',region:'stateProv',country:'countryName',countryCode:'countryCode',continent:'continentName'});break;
    case 'ipdata':o.country=p('country_code');flags({proxy:'threat.is_proxy',tor:'threat.is_tor',server:'threat.is_datacenter'});f.abuser=any(p('threat.is_threat'),p('threat.is_known_abuser'),p('threat.is_known_attacker'));break;
    case 'ipqs':o.country=p('country_code');o.score=risk(id,p('fraud_score'));flags({proxy:'proxy',tor:'tor',vpn:'vpn',abuser:'recent_abuse',robot:'bot_status'});break;
    }
    return o;
    }
    function validateData(id, data, ip) {
    if (!data || typeof data!=='object' || Array.isArray(data)) throw new Error('接口未返回数据对象');
    if (data.error || data.errors || data.errorCode || data.success===false) throw new Error('接口返回错误;请检查权限、配额或 API Key');
    const returned=id==='scamalytics'?(data.scamalytics?.ip??data.ip):id==='maxmind'?data.traits?.ip_address:id==='abuseipdb'?data.data?.ipAddress:id==='dbip'?data.ipAddress:id==='ipinfo'?(data.data?.ip??data.ip):data.ip;
    if(returned) {
    let same=false;try{same=parseIP(returned).ip===parseIP(ip).ip;}catch{}
    if(!same) throw new Error('接口返回的 IP 与目标不一致,已丢弃结果');
    }
    if(id==='scamalytics'&&data.scamalytics?.status&&data.scamalytics.status!=='ok')throw new Error('Scamalytics 返回错误,请检查 User、API Key 和接口权限');
    const n=normalize(id,data);
    if(!n.score && !n.usage && !n.company && !n.country && !Object.values(n.basic).some(v=>v!==null) && !Object.values(n.factors).some(v=>v!==null)) throw new Error('未返回可用字段,可能受限或接口结构已改变');
    return n;
    }
    function migrateScamalytics(settings){
    const key=settings.keys?.scamalytics??'';
    if(!/^https:\/\//i.test(key))return settings;
    try{
    const url=new URL(key),parts=url.pathname.split('/').filter(Boolean);
    if(!/^api[0-9]*\.scamalytics\.com$/.test(url.hostname)||url.username||url.password||!url.searchParams.get('key'))return settings;
    if(!(parts.length===1||parts.length===2&&parts[0]==='v3'))return settings;
    settings.scamalytics={user:decodeURIComponent(parts.at(-1)),endpoint:url.origin+(parts.length===2?'/v3/':'/')};
    settings.keys.scamalytics=url.searchParams.get('key');
    }catch{}
    return settings;
    }
    function requestSpec(id, ip, settings={}) {
    const key=(settings.keys?.[id]??'').trim(), q=encodeURIComponent(ip), k=encodeURIComponent(key);
    const direct=(url,options={})=>({url,options,via:'官方接口'});
    if(id==='maxmind'&&(key||settings.maxmind?.accountId)){
    const account=String(settings.maxmind?.accountId??'').trim();
    if(!account||!key)return {skip:'MaxMind 需同时填写 Account ID 和 License Key'};
    if(!/^\d+$/.test(account)||!/^[\x21-\x7e]+$/.test(key))throw new Error('MaxMind Account ID 应为数字,请检查 License Key 格式');
    const service=MAXMIND_SERVICES[settings.maxmind?.service??'geolite-city'];
    if(!service)throw new Error('请选择有效的 MaxMind 服务');
    return direct(service.url+q,{headers:{Authorization:'Basic '+btoa(account+':'+key),Accept:'application/json'}});
    }
    if(id==='scamalytics'&&(key||settings.scamalytics?.user)){
    const user=String(settings.scamalytics?.user??'').trim();
    if(/^https?:\/\//i.test(key))throw new Error('请分别填写 Scamalytics User 和 API Key,不再填写完整网址');
    if(!key||!user)return {skip:'Scamalytics 需同时填写 User 和 API Key'};
    if(user==='.'||user==='..'||/[\/\\?#\s]/.test(user))throw new Error('Scamalytics User 格式无效,请输入服务商分配的用户名');
    const endpoint=settings.scamalytics?.endpoint??'https://api11.scamalytics.com/v3/&#039;;
    if(!/^https:\/\/api[0-9]*\.scamalytics\.com\/(?:v3\/)?$/.test(endpoint))throw new Error('Scamalytics 服务地址无效');
    const url=new URL(endpoint+encodeURIComponent(user));url.searchParams.set('key',key);url.searchParams.set('ip',ip);
    return direct(url.href);
    }
    if(id==='ipregistry') {
    if(!key) return {skip:'需要填写自己的 Ipregistry API Key'};
    return direct('https://api.ipregistry.co/'+q,{headers:{Authorization:'ApiKey '+key}});
    }
    if(id==='dbip') return direct('https://api.db-ip.com/v2/'+(k||'free')+'/'+q);
    if(id==='ipinfo') return direct(key?'https://ipinfo.io/'+q+'/json?token='+k:'https://ipinfo.io/widget/demo/'+q);
    if(key) {
    if(id==='ipapi')return direct('https://api.ipapi.is/',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({q:ip,key})});
    if(id==='abuseipdb')return direct('https://api.abuseipdb.com/api/v2/check?ipAddress='+q+'&maxAgeInDays=90',{headers:{Key:key,Accept:'application/json'}});
    if(id==='ip2location')return direct('https://api.ip2location.io/?key='+k+'&ip='+q);
    if(id==='ipqs')return direct('https://www.ipqualityscore.com/api/json/ip/'+k+'/'+q);
    if(id==='ipdata')return direct('https://api.ipdata.co/'+q+'?api-key='+k);

    }
    if(settings.relay===false)return {skip:'未启用原脚本接口,且未配置此数据源的自有 API'};
    const db=id==='ipqs'?'ipqualityscore':id;
    return {url:'https://ipinfo.check.place/'+q+(id==='maxmind'?'?lang=en':'?db='+db),via:'原脚本公共接口'};
    }
    function csvCell(v) {
    let s=String(v??'');if(/^[=+\-@\t\r]/.test(s))s="'"+s;
    return '"'+s.replaceAll('"','""')+'"';
    }

    // SPDX-License-Identifier: AGPL-3.0-only
    function jsonRequest(url, options={}, parentSignal, timeout=15000) {
    return new Promise((resolve,reject)=>{
    let request, settled=false;
    const finish=(err,data)=>{if(settled)return;settled=true;parentSignal?.removeEventListener('abort',abort);err?reject(err):resolve(data);};
    const abort=()=>{finish(new Error('已停止'));request?.abort();};
    if(parentSignal?.aborted){abort();return;}
    parentSignal?.addEventListener('abort',abort,{once:true});
    try {
    request=GM_xmlhttpRequest({url,method:options.method??'GET',headers:options.headers??{},data:options.body,
    anonymous:true,timeout,responseType:'text',
    onload:r=>{
    if(r.status<200||r.status>=300){finish(new Error(r.status===429?'HTTP 429:查询配额或速率受限':r.status===401||r.status===403?'HTTP '+r.status+':接口拒绝访问,可能需要自有 API Key':'HTTP '+r.status));return;}
    try{finish(null,JSON.parse(r.responseText));}catch{finish(new Error('接口未返回 JSON,可能受限或已变更'));}
    },
    onerror:()=>finish(new Error('无法连接接口,请检查网络及油猴的域名访问权限')),
    ontimeout:()=>finish(new Error('请求超时')),
    onabort:()=>finish(new Error('已停止'))
    });
    }catch{finish(new Error('请求未能启动,请检查油猴权限'));}
    });
    }
    async function lookupSource(source,ip,settings,signal) {
    const start=Date.now();let via='';
    try {
    const spec=requestSpec(source.id,ip,settings);via=spec.via??'';
    if(spec.skip)return {id:source.id,status:'skipped',error:spec.skip};
    const raw=await jsonRequest(spec.url,spec.options,signal);
    const normalized=validateData(source.id,raw,ip);
    return {id:source.id,status:'ok',via,normalized,raw,ms:Date.now()-start};
    } catch(e) {return {id:source.id,status:signal.aborted?'cancelled':'error',via,error:e.message,ms:Date.now()-start};}
    }
    async function pool(items,concurrency,worker,signal) {
    let index=0;
    await Promise.all(Array.from({length:Math.min(items.length,concurrency)},async()=>{
    while(index<items.length && !signal?.aborted){const i=index++;await worker(items[i],i);}
    }));
    }

    // SPDX-License-Identifier: AGPL-3.0-only
    const $=id=>document.getElementById(id);
    const el=(tag,text,className)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(className)n.className=className;return n;};
    let settings={keys:{},relay:true},run=null,report=null;
    const states={pending:'等待',ok:'已返回',error:'失败',skipped:'未查询',cancelled:'已停止'};
    const sourceLink=(s,ip)=> {
    if(s.id==='ipinfo')return 'https://ipinfo.io/'+ip;
    if(s.id==='scamalytics')return 'https://scamalytics.com/ip/'+ip;
    if(s.id==='abuseipdb')return 'https://www.abuseipdb.com/check/'+ip;
    if(s.id==='ipqs')return 'https://www.ipqualityscore.com/free-ip-lookup-proxy-vpn-test/lookup/'+ip;
    return s.web;
    };
    const riskClass=v=>/高|风险|DoS/.test(v)?'bad':/中等|可疑/.test(v)?'warn':/低/.test(v)?'good':'';
    const badge=(text,cls='')=>el('span',text,'badge '+cls);
    const cell=(row,value)=>{const c=el('td');c.append(value instanceof Node?value:String(value??'—'));row.append(c);};
    function setBusy(busy){
    for(const id of ['ip','start','settingsButton'])$(id).disabled=busy;
    $('stop').disabled=!busy;
    }
    function updateProgress(){
    if(!report)return;
    const db=Object.values(report.sources).filter(x=>x.status!=='pending').length;
    $('progress').max=SOURCES.length;
    $('progress').value=db;
    $('status').textContent=(run?'查询中':'本轮结束')+' · 数据库 '+db+'/'+SOURCES.length;
    }
    const displayNames={ipapi:'ipapi',ipregistry:'ipregistry',ip2location:'IP2Location',abuseipdb:'AbuseIPDB',ipinfo:'IPinfo',scamalytics:'Scamalytics',ipqs:'IPQS',dbip:'DB-IP',ipdata:'ipdata'};
    function typeBadge(value){
    if(value==null)return el('span','—','unknown');
    const text=String(value),key=text.toLowerCase();
    const map={hosting:['机房','bad'],dch:['机房','bad'],'data center/web hosting/transit':['机房','bad'],isp:['ISP','good'],business:['商业','warn'],com:['商业','warn'],education:['教育','warn'],edu:['教育','warn'],government:['政府','warn'],gov:['政府','warn'],banking:['银行','warn'],cdn:['CDN','bad'],mob:['移动','good'],mobile:['移动','good']};
    const match=map[key]??[text,''];const node=badge(match[0],match[1]+' type-badge');node.title=text;return node;
    }
    function matrixHead(id,columns){const row=el('tr');row.append(el('th','数据库:'));for(const key of columns)row.append(el('th',displayNames[key]));$(id).replaceChildren(row);}
    function renderDB(){
    const rows=SOURCES.map(s=>({...s,...(report?.sources[s.id]??{status:'pending'})}));
    const byId=Object.fromEntries(rows.map(r=>[r.id,r]));
    const basicRow=['maxmind','ipinfo','ipapi','dbip'].map(id=>byId[id]).find(r=>r.status==='ok'&&Object.values(r.normalized.basic).some(v=>v!==null));
    const b=basicRow?.normalized.basic??{};
    $('basic').replaceChildren();$('basicSource').textContent=basicRow?'('+basicRow.name+' 数据库)':'(等待数据)';
    const country=regionLabel(b.countryCode,b.country);
    const city=[b.region,b.city,b.postal].filter(Boolean).join(', ');
    const asn=b.asn==null?null:String(b.asn).startsWith('AS')?b.asn:'AS'+b.asn;
    const facts=[['ASN',asn],['组织',b.org],['坐标',b.coordinates??(b.latitude!=null&&b.longitude!=null?b.latitude+', '+b.longitude:null)],['城市',city||null],['地理地区',country],['注册地区',regionLabel(b.registered)],['时区',b.timezone]];
    for(const [label,value]of facts){const box=el('div',undefined,'fact');box.append(el('span',label+':','fact-label'),el('span',value??'—',value==null?'unknown':'fact-value'));$('basic').append(box);}
    const typeColumns=['ipinfo','ipregistry','ipapi','ip2location','abuseipdb'];
    matrixHead('typesHead',typeColumns);$('types').replaceChildren();
    for(const [key,label]of [['usage','用途:'],['company','公司:']]){const row=el('tr');row.append(el('th',label));for(const id of typeColumns)cell(row,typeBadge(byId[id].normalized?.[key]));$('types').append(row);}
    const factorColumns=['ip2location','ipapi','ipregistry','ipqs','scamalytics','ipdata','ipinfo','dbip'];
    matrixHead('factorsHead',factorColumns);$('factors').replaceChildren();
    for(const [key,label]of [['country','地区:'],['proxy','代理:'],['tor','Tor:'],['vpn','VPN:'],['server','服务器:'],['abuser','滥用:'],['robot','机器人:']]){
    const row=el('tr');row.append(el('th',label));
    for(const id of factorColumns){
    const source=byId[id],n=source.normalized;
    if(source.status!=='ok'||!n){cell(row,Object.assign(el('span','-','unknown'),{title:'数据库未返回可用结果'}));continue;}
    const v=key==='country'?n.country:n.factors[key];
    cell(row,key==='country'?(v?el('span',v,'country-code'):Object.assign(badge('无','good'),{title:'未提供此项结果'})):v===true?badge('是','bad'):v===false?badge('否','good'):Object.assign(badge('无','good'),{title:'未提供此项结果'}));
    }$('factors').append(row);
    }
    $('scores').replaceChildren();
    for(const id of ['ip2location','scamalytics','ipapi','abuseipdb','ipqs','dbip']){
    const r=byId[id],score=r.normalized?.score;
    const row=el('div',undefined,'score-row');row.append(el('span',displayNames[id]+':','score-name'));
    const track=el('div',undefined,'score-track'+(score?'':' no-score'));
    if(score){
    const index=/极低/.test(score.level)?0:score.level==='低'?1:/中等|可疑/.test(score.level)?2:/极高|高风险/.test(score.level)?4:3;
    if(score.value!==null){
    const percent=Math.max(1,scoreWidth(id,score.value));
    const fill=el('div',undefined,'score-fill band-'+index);fill.style.width=percent+'%';
    const label=el('span',undefined,'score-end-label');label.style.left=percent+'%';
    label.append(el('span',score.text,'score-value'),badge(score.level,riskClass(score.level)));track.append(fill,label);
    track.setAttribute('role','meter');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(percent));track.setAttribute('aria-valuetext',score.text+','+score.level);
    }else{track.classList.add('qualitative');track.append(el('span',score.text,'score-unavailable'),badge(score.level,riskClass(score.level)));}
    track.setAttribute('aria-label',displayNames[id]+' '+score.text+','+score.level);
    const rule=id==='ipapi'?'低 <1%;中等 1%–<3%;高 ≥3%':id==='ipqs'?'低 <75;中等 75–<90;高 ≥90':id==='dbip'?'仅显示数据库返回的等级':'极低 <10;低 10–<20;中等 20–<40;高 40–<60;极高 ≥60';
    track.title=score.scope+';'+rule+';条形按风险分界值分段线性换算,视觉最短为 1%;完整数值见条末标签';
    }else track.append(el('span',r.status==='pending'?'—':r.status==='ok'?'未提供评分':r.status==='error'?'查询失败':r.status==='skipped'?'未查询':'已停止','score-unavailable'));
    row.append(track);

    $('scores').append(row);
    }
    $('scoreNetwork').textContent=byId.ipapi.normalized?.network??'—';
    $('sources').replaceChildren();
    for(const r of rows){const n=r.normalized;const row=el('div',undefined,'source-row'),a=el('a',r.name);a.href=report?sourceLink(r,report.ip):r.web;a.target='_blank';a.rel='noreferrer';
    row.append(a,badge(states[r.status],r.status==='ok'?'good':r.status==='error'?'warn':''),el('span',r.error??(r.via?r.via+' · '+r.ms+' ms'+(r.id==='dbip'&&!n?.score?' · 无风控字段':''):'等待查询')));$('sources').append(row);
    }
    const count=rows.filter(r=>r.status==='ok').length;$('sourceCount').textContent=report?count+'/'+SOURCES.length+' 已返回':'等待查询';
    updateProgress();
    }
    async function start(event){
    event.preventDefault();if(run)return;
    let parsed;try{parsed=parseIP($('ip').value);}catch(e){$('status').textContent=e.message;return;}
    if(parsed.private){$('status').textContent='这是私有、共享、回环或保留地址,公网风控数据库不适用。请输入公网 IP。';return;}
    $('ip').value=parsed.ip;run=new AbortController();const signal=run.signal;setBusy(true);
    $('exportJson').disabled=true;$('exportCsv').disabled=true;
    report={tool:'IP 质量查询',version:'2.10.1',ip:parsed.ip,ipVersion:parsed.version,startedAt:new Date().toISOString(),completed:false,sources:Object.fromEntries(SOURCES.map(s=>[s.id,{status:'pending'}]))};
    $('reportTitle').textContent=parsed.ip;
    $('reportTime').textContent='检测时间:'+report.startedAt.slice(0,19).replace('T',' ')+' UTC';
    $('reportFoot').textContent='正在查询 IPv'+parsed.version+' · '+parsed.ip;
    renderDB();
    const taskSettings=structuredClone(settings);
    try {
    await pool(SOURCES,3,async source=>{report.sources[source.id]=await lookupSource(source,parsed.ip,taskSettings,signal);renderDB();},signal);
    report.completed=!signal.aborted;
    }catch(e){report.error=e.message;}
    finally {
    for(const r of Object.values(report.sources))if(r.status==='pending'){r.status='cancelled';r.error=signal.aborted?'已停止':'未执行';}
    report.finishedAt=new Date().toISOString();report.cancelled=signal.aborted;run=null;setBusy(false);renderDB();
    const ok=Object.values(report.sources).filter(r=>r.status==='ok').length;
    $('status').textContent=(report.error?'查询中断:'+report.error:report.cancelled?'已停止,保留已返回结果':'查询结束')+' · 数据源成功 '+ok+'/'+SOURCES.length;
    $('notice').textContent=ok<SOURCES.length?'部分数据源未返回。查看下方状态;需要权限的接口可在设置中填写自有 API。缺失评分不代表低风险。':'报告仅反映各数据库当前记录;不同库的评分不能直接平均或互相替代。';
    $('reportFoot').textContent=(report.cancelled?'查询已停止':report.error?'查询中断':'查询完成')+' · 数据源 '+ok+'/'+SOURCES.length+' · IPv'+parsed.version;
    $('exportJson').disabled=false;$('exportCsv').disabled=false;
    }
    }
    function publicReport(){
    // Sanitize source responses as well as metadata; settings are never exported.
    const secrets=Object.values(settings.keys).filter(v=>v&&v.length>=4);
    if(settings.keys.scamalytics){
    try{
    const u=new URL(settings.keys.scamalytics.replaceAll('{ip}',encodeURIComponent(report.ip)));
    secrets.push(u.href,...[...u.searchParams.values()].filter(v=>v.length>=4 && v!==report.ip),...u.pathname.split('/').filter(v=>v.length>=8 && v!==report.ip));
    }catch{}
    }
    return JSON.parse(JSON.stringify(report,(key,value)=>{
    if(/^(key|api[-_]?key|token|authorization|password|secret)$/i.test(key))return '[redacted]';
    if(typeof value==='string')for(const secret of secrets)value=value.replaceAll(secret,'[redacted]').replaceAll(encodeURIComponent(secret),'[redacted]');
    return value;
    }));
    }
    function download(text,mime,extension){
    const blob=new Blob([text],{type:mime});const url=URL.createObjectURL(blob),a=el('a');
    a.href=url;a.download='ip-quality-'+report.ip.replaceAll(':','_')+'.'+extension;a.click();setTimeout(()=>URL.revokeObjectURL(url),3000);
    }
    async function screenshotReport(){
    const button=$('screenshot');button.disabled=true;button.textContent='截图中…';
    let frame;
    try{
    // Create the capture document only after a click, with no third-party runtime.
    const css=[...document.querySelectorAll('style')].map(n=>n.textContent).join('\n');
    frame=el('iframe');frame.setAttribute('aria-hidden','true');frame.tabIndex=-1;
    frame.style.cssText='position:fixed;left:-20000px;top:0;width:1280px;height:1000px;border:0;pointer-events:none';
    document.body.append(frame);
    const doc=frame.contentDocument;
    const style=doc.createElement('style');style.textContent=css;doc.head.append(style);
    const target=doc.importNode(document.querySelector('.terminal'),true);
    target.style.cssText='width:1072px;max-width:none;box-sizing:border-box;box-shadow:none;margin:0';
    doc.body.style.cssText='margin:0;padding:0';doc.body.append(target);
    await doc.fonts.ready;
    const width=1072,height=Math.ceil(target.getBoundingClientRect().height);
    if(height<1||height>12000)throw new Error('报告尺寸异常');
    const wrapper=doc.createElement('div');wrapper.setAttribute('xmlns','http://www.w3.org/1999/xhtml');
    wrapper.style.cssText='color:#ebedf2;background:#101216;font:13px/1.5 system-ui,Segoe UI,Microsoft YaHei,sans-serif';
    wrapper.append(style.cloneNode(true),target.cloneNode(true));
    const xml=new XMLSerializer().serializeToString(wrapper);
    const svg='<svg xmlns="http://www.w3.org/2000/svg&quot; width="'+width+'" height="'+height+'"><foreignObject width="100%" height="100%">'+xml+'</foreignObject></svg>';
    const img=new Image();
    await new Promise((resolve,reject)=>{
    const timer=setTimeout(()=>reject(new Error('图片生成超时,请重试')),15000);
    img.onload=()=>{clearTimeout(timer);resolve();};img.onerror=()=>{clearTimeout(timer);reject(new Error('浏览器无法绘制报告'));};
    img.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(svg);
    });
    const canvas=document.createElement('canvas');canvas.width=width2;canvas.height=height2;
    const ctx=canvas.getContext('2d');ctx.fillStyle='#101216';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.drawImage(img,0,0,canvas.width,canvas.height);
    const blob=await new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('图片生成失败')),'image/png'));
    const url=URL.createObjectURL(blob),a=el('a');a.href=url;a.download='ip-quality-'+(report?.ip??'report').replaceAll(':','_')+'-'+new Date().toISOString().replace(/[:.]/g,'-')+'.png';a.click();setTimeout(()=>URL.revokeObjectURL(url),60000);
    button.title='图片已生成并提交浏览器下载';
    }catch(e){$('status').textContent='截图失败:'+e.message;}
    finally{frame?.remove();button.disabled=false;button.textContent='截图';}
    }
    $('screenshot').onclick=screenshotReport;
    function exportCSV(){
    const r=publicReport(),rows=[['模块','数据源','字段','结果','状态/说明']];
    for(const [id,source]of Object.entries(r.sources)){
    rows.push(['数据源',id,'状态',source.status,source.error??source.via??'']);const n=source.normalized;if(!n)continue;
    for(const [field,value]of Object.entries(n.basic))rows.push(['1 基础信息',id,field,value,'']);
    for(const field of ['usage','company','country','network'])rows.push(['2 IP类型',id,field,n[field],'']);
    rows.push(['3 风险评分',id,'score',n.score?.text,n.score?.level]);
    for(const [field,value]of Object.entries(n.factors))rows.push(['4 风险因子',id,field,value===null?'未知':value?'是':'否','']);
    }
    rows.unshift(['目标 IP',r.ip,'查询时间',r.startedAt,r.completed?'完成':'部分结果']);
    download('\uFEFF'+rows.map(row=>row.map(csvCell).join(',')).join('\r\n'),'text/csv;charset=utf-8','csv');
    }
    const keySources=SOURCES;
    for(const source of keySources){
    if(source.id==='scamalytics'){
    const label=el('label',undefined,'keyfield'),input=el('input');input.id='scamalyticsUser';input.autocomplete='off';input.spellcheck=false;input.placeholder='Scamalytics 分配的 User';label.append(el('span','Scamalytics User'),input);$('keyFields').append(label);
    }
    if(source.id==='maxmind'){
    const accountLabel=el('label',undefined,'keyfield'),account=el('input');account.id='maxmindAccount';account.type='text';account.inputMode='numeric';account.pattern='[0-9]*';account.autocomplete='off';account.placeholder='MaxMind 账户中的数字 Account ID';accountLabel.append(el('span','MaxMind Account ID'),account);$('keyFields').append(accountLabel);
    const serviceLabel=el('label',undefined,'keyfield'),select=el('select');select.id='maxmindService';
    for(const [id,item]of Object.entries(MAXMIND_SERVICES)){const option=el('option',item.name);option.value=id;select.append(option);}
    serviceLabel.append(el('span','MaxMind 服务'),select);$('keyFields').append(serviceLabel);
    }
    const label=el('label',undefined,'keyfield'),title=el('span',source.name+(source.id==='maxmind'?' License Key':source.id==='scamalytics'?' API Key':' API Key / Token'));
    const link=el('a','官网');link.href=source.web;link.target='_blank';link.rel='noreferrer';title.append(link);
    const input=el('input');input.type='password';input.id='key-'+source.id;input.autocomplete='off';input.spellcheck=false;input.placeholder='可选,填写你自己的密钥';label.append(title,input);$('keyFields').append(label);
    }
    $('settingsButton').onclick=()=>{$('scamalyticsUser').value=settings.scamalytics?.user??'';$('maxmindAccount').value=settings.maxmind?.accountId??'';$('maxmindService').value=settings.maxmind?.service??'geolite-city';for(const s of keySources)$('key-'+s.id).value=settings.keys[s.id]??'';$('relay').checked=settings.relay;$('settings').showModal();};
    $('closeSettings').onclick=()=>$('settings').close();
    $('settingsForm').onsubmit=async e=>{
    e.preventDefault();settings={scamalytics:{user:$('scamalyticsUser').value.trim(),endpoint:settings.scamalytics?.endpoint??'https://api11.scamalytics.com/v3/'},maxmind:{accountId:$('maxmindAccount').value.trim(),service:$('maxmindService').value},relay:$('relay').checked,keys:Object.fromEntries(keySources.map(s=>[s.id,$('key-'+s.id).value.trim()]))};
    try{if($('remember').checked)await GM_setValue('settings',settings);else await GM_deleteValue('settings');$('settings').close();}catch{$('status').textContent='配置已应用于当前页面,但本机保存失败';$('settings').close();}
    };
    $('clearKeys').onclick=async()=>{await GM_deleteValue('settings');settings={relay:true,keys:{}};$('scamalyticsUser').value='';$('maxmindAccount').value='';$('maxmindService').value='geolite-city';for(const s of keySources)$('key-'+s.id).value='';$('relay').checked=true;$('remember').checked=false;};
    $('queryForm').addEventListener('submit',start);$('stop').onclick=()=>run?.abort();
    $('exportJson').onclick=()=>download(JSON.stringify(publicReport(),null,2),'application/json','json');$('exportCsv').onclick=exportCSV;
    try{const saved={settings:await GM_getValue('settings',null)};if(saved.settings){settings={scamalytics:saved.settings.scamalytics,maxmind:saved.settings.maxmind,relay:saved.settings.relay!==false,keys:saved.settings.keys??{}};$('remember').checked=true;}}catch{}

    migrateScamalytics(settings);
    renderDB();

    })().catch(error=>{console.error('IP 质量体检初始化失败',error);});

    主楼
  • SeaMod

    ^-^

    #1
  • 可鸽可弃

    这个怎么用 有点没看明白

    #2
  • hiroqu

    这个功能有点鸡肋,直接去在线网站查也一样吧。

    #3
  • AngeloSakura

    bd

    #4
  • SeaMod

    ^-^

    #5
  • PassionArie-nodeseek

    有点意思。。

    #6
  • xpro

    比网页操作麻烦了

    #7
  • waterflow

    费那老劲注册拿key,打开ipsuper直接查就是了……

    #8
  • SeaMod

    #9

发表回复

登录后回复