GPT老师,改自Scriptable脚本。

<!doctype html><html lang="zh-CN"><head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" /> <meta name="color-scheme" content="dark" /> <title>IP风险值</title> <style> :root{ --txt:#eaf2ff; --muted:rgba(234,242,255,.68); --muted2:rgba(234,242,255,.46); --line:rgba(160,200,255,.18); --accent:#82b7ff; --good:#39d98a; --warn:#ffb347; --bad:#ff5d5d; --shadow:0 14px 34px rgba(0,0,0,.34), inset 0 1px 0 rgba(255,255,255,.05); }
html, body{ margin:0; width:100%; height:100%; overflow:hidden; background:transparent; font-family:"Microsoft YaHei", sans-serif; -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility; }
*{box-sizing:border-box} button, input{font:inherit}
/* 主容器:完整铺满宿主区域 */ .card{ width:100%; height:100%; position:relative; overflow:hidden; border-radius:18px; border:1px solid var(--line); background: radial-gradient(120% 100% at 0% 0%, rgba(130,183,255,.14), transparent 58%), radial-gradient(90% 120% at 100% 0%, rgba(167,139,250,.10), transparent 50%), linear-gradient(160deg, rgba(15,25,39,.98) 0%, rgba(10,17,28,.98) 100%); box-shadow:var(--shadow); color:var(--txt); padding:14px 14px 12px; display:flex; flex-direction:column; gap:10px; }
/* 轻微内高光 */ .card::before{ content:""; position:absolute; inset:0; border-radius:18px; padding:1px; background:linear-gradient(180deg, rgba(255,255,255,.13), rgba(255,255,255,0) 26%, rgba(255,255,255,0) 80%, rgba(255,255,255,.07)); -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite:xor; mask-composite:exclude; pointer-events:none; opacity:.9; }
.card::after{ content:""; position:absolute; inset:-2px; border-radius:20px; background:linear-gradient(135deg, rgba(255,255,255,.06), transparent 28%, transparent 72%, rgba(255,255,255,.04)); pointer-events:none; mix-blend-mode:screen; opacity:.55; }
.topbar{ position:relative; z-index:1; display:flex; align-items:flex-start; justify-content:space-between; gap:10px; min-height:34px; }
.title-wrap{ min-width:0; display:flex; flex-direction:column; gap:4px; flex:1 1 auto; }
.title{ margin:0; font-size:15px; line-height:1.1; font-weight:700; letter-spacing:.2px; color:var(--txt); user-select:none; }
.title .sub{ font-weight:600; color:var(--muted); margin-left:6px; font-size:12px; }
.actions{ display:flex; align-items:center; gap:8px; flex:0 0 auto; -webkit-app-region:no-drag; app-region:no-drag; }
.btn{ appearance:none; border:none; outline:none; height:28px; padding:0 11px; border-radius:10px; color:var(--txt); background:linear-gradient(180deg, rgba(130,183,255,.16), rgba(130,183,255,.08)); border:1px solid rgba(130,183,255,.22); box-shadow:inset 0 1px 0 rgba(255,255,255,.08); cursor:pointer; transition:transform .12s ease, background .12s ease, border-color .12s ease, opacity .12s ease, box-shadow .12s ease; font-size:12px; font-weight:700; letter-spacing:.2px; user-select:none; white-space:nowrap; }
.btn:hover{ background:linear-gradient(180deg, rgba(130,183,255,.22), rgba(130,183,255,.12)); border-color:rgba(130,183,255,.34); transform:translateY(-1px); box-shadow:inset 0 1px 0 rgba(255,255,255,.10); }
.btn:active{ transform:translateY(0); opacity:.92; }
.btn.secondary{ background:rgba(255,255,255,.04); border-color:rgba(255,255,255,.09); color:var(--muted); }
.btn.secondary:hover{ background:rgba(255,255,255,.07); border-color:rgba(160,200,255,.14); color:var(--txt); }
.btn[disabled]{ cursor:not-allowed; opacity:.58; transform:none; }
/* API 编辑面板:只保留编辑能力,不再显示多余状态条 */ .api-panel{ position:relative; z-index:1; display:none; flex-direction:column; gap:7px; padding:10px 11px; border-radius:14px; border:1px solid rgba(160,200,255,.14); background:rgba(255,255,255,.03); box-shadow:inset 0 1px 0 rgba(255,255,255,.04); }
.api-panel.show{display:flex}
.api-top{ display:flex; align-items:center; justify-content:space-between; gap:10px; min-width:0; }
.api-title{ font-size:12px; font-weight:700; color:#f3f8ff; white-space:nowrap; }
.api-hint{ font-size:10px; color:var(--muted2); line-height:1.35; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.api-row{ display:flex; gap:8px; align-items:center; }
.api-input{ width:100%; min-width:0; height:30px; border-radius:10px; border:1px solid rgba(160,200,255,.16); background:rgba(6,12,20,.54); color:var(--txt); padding:0 10px; outline:none; box-shadow:inset 0 1px 0 rgba(255,255,255,.04); transition:border-color .12s ease, background .12s ease, box-shadow .12s ease; font-size:12px; }
.api-input:hover{ border-color:rgba(130,183,255,.28); background:rgba(8,14,24,.62); }
.api-input:focus{ border-color:rgba(130,183,255,.42); box-shadow:0 0 0 3px rgba(130,183,255,.10), inset 0 1px 0 rgba(255,255,255,.04); }
.api-actions{ display:flex; gap:8px; flex:0 0 auto; -webkit-app-region:no-drag; app-region:no-drag; }
.content{ position:relative; z-index:1; flex:1 1 auto; min-height:0; display:grid; grid-template-columns:104px minmax(0,1fr); gap:12px; align-items:center; }
.scorebox{ width:104px; height:104px; border-radius:22px; position:relative; display:flex; align-items:center; justify-content:center; background: radial-gradient(circle at 50% 28%, rgba(255,255,255,.08), transparent 38%), linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02)); border:1px solid rgba(255,255,255,.08); box-shadow:inset 0 1px 0 rgba(255,255,255,.06); overflow:hidden; }
.ring{ width:80px; height:80px; border-radius:50%; background: radial-gradient(circle at center, rgba(9,17,28,.98) 0 46%, transparent 47%), conic-gradient(var(--ring-color) var(--ring-angle), rgba(255,255,255,.08) 0); display:flex; align-items:center; justify-content:center; position:relative; box-shadow:0 0 0 1px rgba(255,255,255,.05) inset; }
.ring::before{ content:""; position:absolute; inset:6px; border-radius:50%; border:1px solid rgba(255,255,255,.06); box-shadow:inset 0 1px 0 rgba(255,255,255,.05); }
.score-inner{ position:relative; z-index:1; text-align:center; line-height:1; }
.score{ font-size:24px; font-weight:800; letter-spacing:-.6px; color:var(--ring-color); }
.score-unit{ display:block; margin-top:4px; font-size:10px; color:var(--muted2); letter-spacing:.8px; }
.score-tag{ position:absolute; bottom:7px; left:50%; transform:translateX(-50%); font-size:10px; color:var(--muted2); letter-spacing:.4px; white-space:nowrap; }
.info{ min-width:0; display:flex; flex-direction:column; gap:7px; }
.row{ display:flex; align-items:flex-start; gap:8px; min-width:0; }
.label{ flex:0 0 auto; width:42px; color:var(--muted2); font-size:11px; line-height:18px; letter-spacing:.2px; }
.value{ min-width:0; flex:1 1 auto; color:var(--txt); font-size:12px; line-height:18px; word-break:break-all; }
.value.muted{color:var(--muted)}
.chips{ display:flex; flex-wrap:wrap; gap:6px; margin-top:1px; }
.chip{ height:22px; padding:0 8px; display:inline-flex; align-items:center; border-radius:999px; font-size:11px; border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.04); color:var(--muted); white-space:nowrap; }
.chip.good{ color:#bff6de; border-color:rgba(57,217,138,.2); background:rgba(57,217,138,.08); }
.chip.warn{ color:#ffe0b3; border-color:rgba(255,179,71,.2); background:rgba(255,179,71,.08); }
.chip.bad{ color:#ffd0d0; border-color:rgba(255,93,93,.2); background:rgba(255,93,93,.08); }
.bottom{ position:relative; z-index:1; display:flex; justify-content:space-between; align-items:center; gap:10px; min-height:28px; }
.status{ min-width:0; display:flex; flex-direction:column; gap:2px; justify-content:center; }
.status .line1{ font-size:11px; color:var(--muted); line-height:1.1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.status .line2{ font-size:10px; color:var(--muted2); line-height:1.1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.loading{ display:inline-flex; align-items:center; gap:8px; }
.spinner{ width:12px; height:12px; border-radius:50%; border:2px solid rgba(255,255,255,.14); border-top-color:rgba(130,183,255,.9); animation:spin .85s linear infinite; flex:0 0 auto; }
@keyframes spin{to{transform:rotate(360deg)}}
.skeleton{ position:relative; overflow:hidden; background:rgba(255,255,255,.05); border-radius:8px; color:transparent !important; min-height:12px; }
.skeleton::after{ content:""; position:absolute; inset:0; transform:translateX(-100%); background:linear-gradient(90deg, transparent, rgba(255,255,255,.12), transparent); animation:shimmer 1.2s infinite; }
@keyframes shimmer{ 100%{transform:translateX(100%)} }
.error{color:#ffd0d0 !important}
.yanm-drag,[data-yanm-drag="true"]{ -webkit-app-region:drag; app-region:drag; }
.no-drag, button, input{ -webkit-app-region:no-drag; app-region:no-drag; }
.resize-handle{ position:absolute; right:7px; bottom:6px; width:18px; height:18px; border-radius:6px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(135deg, rgba(255,255,255,.08), rgba(255,255,255,.02)); display:flex; align-items:center; justify-content:center; cursor:nwse-resize; opacity:.8; z-index:2; -webkit-app-region:no-drag; app-region:no-drag; }
.resize-handle::before{ content:""; width:8px; height:8px; border-right:1px solid rgba(234,242,255,.55); border-bottom:1px solid rgba(234,242,255,.55); transform:translate(1px,1px); }
.tiny{ font-size:10px; color:var(--muted2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
@media (max-width:330px){ .card{padding:12px 12px 10px} .content{grid-template-columns:94px minmax(0,1fr)} .scorebox{width:94px;height:94px;border-radius:20px} .ring{width:74px;height:74px} .score{font-size:22px} .label{width:38px} } </style></head><body> <div class="card" id="app"> <div class="topbar"> <div class="title-wrap"> <div class="title yanm-drag" data-yanm-drag="true"><span class="sub">IP风险值</span></div> </div> <div class="actions"> <button class="btn secondary" id="copyBtn" type="button">复制IP</button> <button class="btn secondary" id="editApiBtn" type="button">编辑API</button> <button class="btn" id="refreshBtn" type="button">刷新</button> </div> </div>
<div class="api-panel" id="apiPanel"> <div class="api-top"> <div class="api-title">风险 API</div> <div class="api-hint" id="apiHint">支持 {ip} 占位符;若省略会自动追加。</div> </div> <div class="api-row"> <input id="apiInput" class="api-input" type="text" spellcheck="false" autocomplete="off" /> </div> <div class="api-row" style="justify-content:space-between;"> <div class="tiny" id="apiTiny">当前保存到 localStorage 与燕幕宿主状态。</div> <div class="api-actions"> <button class="btn secondary" id="resetApiBtn" type="button">恢复默认</button> <button class="btn" id="saveApiBtn" type="button">保存API</button> </div> </div> </div>
<div class="content"> <div class="scorebox"> <div class="ring" id="ring" style="--ring-angle:0deg;--ring-color:var(--accent)"> <div class="score-inner"> <div class="score" id="scoreText">--</div> <span class="score-unit">RISK</span> </div> </div> <div class="score-tag" id="riskTag">风险等级</div> </div>
<div class="info"> <div class="row"> <div class="label">IP</div> <div class="value" id="ipText">正在获取…</div> </div> <div class="row"> <div class="label">位置</div> <div class="value muted" id="locationText">正在获取…</div> </div> <div class="row"> <div class="label">ISP</div> <div class="value muted" id="ispText">正在获取…</div> </div> <div class="row"> <div class="label">判断</div> <div class="chips" id="chips"></div> </div> </div> </div>
<div class="bottom"> <div class="status"> <div class="line1" id="statusLine"> <span class="loading"><span class="spinner"></span><span>正在拉取 IP 数据</span></span> </div> <div class="line2" id="subStatus">宿主请求失败时会回退到本地缓存。</div> </div> <div class="tiny no-drag" id="updatedAt">--</div> </div>
<div class="resize-handle" data-yanm-resize="true" title="拖动调整大小"></div> </div>
<script> (() => { // 存储键:IP 信息缓存 const STORE_KEY = "yanm.ipinfo.widget.cache.v3"; // 存储键:风险 API 模板 const API_KEY = "yanm.ipinfo.riskapi.template.v1"; // 默认风险 API(保留 {ip} 占位符) const DEFAULT_RISK_API_TEMPLATE = "https://api12.scamalytics.com/xxxx/?key=xxxx&ip={ip}"; // IP 地理信息接口 const IP_API_URL = "http://ip-api.com/json/?lang=zh-CN";
const $ = (id) => document.getElementById(id);
const state = { loading: false, error: "", data: null, updatedAt: "", source: "", apiTemplate: DEFAULT_RISK_API_TEMPLATE, apiEditorOpen: false, hostReady: false };
// 当前时间格式化 function nowText(ts = Date.now()) { const d = new Date(ts); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
function safeText(v, fallback = "未知") { if (v === null || v === undefined) return fallback; const s = String(v).trim(); return s ? s : fallback; }
function escapeHTML(str) { return String(str).replace(/[&<>"']/g, (m) => ({ "&":"&", "<":"<", ">":">", "\"":""", "'":"'" }[m])); }
// 兼容宿主 state.get 返回不同结构 function normalizeStateValue(res) { if (res == null) return null; if (typeof res === "string") return res; if (typeof res === "object") { if (typeof res.value === "string") return res.value; if (typeof res.text === "string") return res.text; if (typeof res.data === "string") return res.data; if (typeof res.result === "string") return res.result; try { return JSON.stringify(res); } catch (_) { return null; } } return String(res); }
function parseJSONMaybe(raw) { if (raw == null) return null; if (typeof raw === "object") return raw; if (typeof raw === "string") { try { return JSON.parse(raw); } catch (_) { return raw; } } return null; }
// 本地缓存读取 function loadStored(key) { try { const raw = localStorage.getItem(key); if (!raw) return null; return parseJSONMaybe(raw); } catch (_) { return null; } }
// 本地缓存写入 function saveLocal(key, data) { try { localStorage.setItem(key, JSON.stringify(data)); } catch (_) {} }
// 宿主状态保存 async function saveHost(key, data) { try { if (window.yanm && typeof window.yanm.invoke === "function") { const ret = window.yanm.invoke("state.set", { key, value: JSON.stringify(data) }); if (ret && typeof ret.then === "function") { await ret.catch(() => {}); } } } catch (_) {} }
// 宿主状态读取 async function readHost(key) { try { if (!window.yanm || typeof window.yanm.invoke !== "function") return null; const ret = window.yanm.invoke("state.get", { key: key }); const res = ret && typeof ret.then === "function" ? await ret : ret; return parseJSONMaybe(normalizeStateValue(res)); } catch (_) { return null; } }
// 按“先内存、后 localStorage、再宿主”思路保存 async function persist(key, data) { saveLocal(key, data); await saveHost(key, data); }
// 包一层宿主调用,避免同步写法 async function invokeYanm(method, args) { if (!window.yanm || typeof window.yanm.invoke !== "function") { throw new Error("宿主未就绪"); } const ret = window.yanm.invoke(method, args); return ret && typeof ret.then === "function" ? await ret : ret; }
// 通过宿主 HTTP GET 获取文本 async function getTextByHttp(url, timeoutMs = 10000) { const res = await invokeYanm("http.get", { url, headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) WebView2/121.0 Safari/537.36", "Accept": "application/json,text/plain,*/*" }, timeoutMs });
if (typeof res === "string") return res;
if (res && typeof res === "object") { if (res.ok === false) { throw new Error(res.error || res.message || ("HTTP " + (res.status || "ERR"))); } if (typeof res.text === "string") return res.text; if (typeof res.content === "string") return res.content; if (typeof res.body === "string") return res.body; if (typeof res.data === "string") return res.data; return JSON.stringify(res); }
return String(res || ""); }
async function getJsonByHttp(url, timeoutMs = 10000) { const text = await getTextByHttp(url, timeoutMs); try { return JSON.parse(text); } catch (_) { throw new Error("返回内容不是有效 JSON"); } }
// 本地风控兜底规则 function calculateRiskDetails(isp, org, country, regionName, city) { const text = `${safeText(isp, "")} ${safeText(org, "")} ${safeText(country, "")} ${safeText(regionName, "")} ${safeText(city, "")}`.toLowerCase();
const homeBroadbandKeywords = [ "电信", "移动", "联通", "宽带", "china mobile", "china unicom", "chinanet", "comcast", "verizon", "spectrum", "cox", "frontier", "optimum", "sky", "virgin media", "bt " ]; const hostingKeywords = [ "vps", "vpn", "proxy", "hosting", "cloud", "server", "datacenter", "data center", "colo", "colocation", "dedicated", "aws", "amazon", "google", "microsoft", "azure", "digitalocean", "linode", "ovh", "hetzner", "vultr", "contabo", "leaseweb", "alibaba", "tencent", "oracle", "cloudflare" ];
let score = 18; if (homeBroadbandKeywords.some(k => text.includes(k.toLowerCase()))) score -= 12; if (hostingKeywords.some(k => text.includes(k.toLowerCase()))) score += 38; if (/(vpn|proxy|hosting|server|cloud|datacenter|colo|dedicated)/i.test(text)) score += 18; if (/(residential|broadband|fiber|cable|dial|consumer)/i.test(text)) score -= 8; if (/(mobile|wireless|cellular)/i.test(text)) score -= 4; if (/(tor|anon|anonymous|privacy)/i.test(text)) score += 20;
score = clamp(Math.round(score), 0, 100);
return { scamScore: score, isHomeBroadband: homeBroadbandKeywords.some(k => text.includes(k.toLowerCase())) ? "是家宽" : "非家宽", isNativeIP: score < 50 ? "原生" : "非原生" }; }
function getRiskColor(riskValue) { if (riskValue < 20) return "#39d98a"; if (riskValue <= 50) return "#ffb347"; return "#ff5d5d"; }
function getRiskTag(riskValue) { if (riskValue < 20) return "低风险"; if (riskValue <= 50) return "中风险"; return "高风险"; }
function buildRiskUrl(template, ip) { const raw = safeText(template, DEFAULT_RISK_API_TEMPLATE).trim(); if (!raw) return DEFAULT_RISK_API_TEMPLATE.replace("{ip}", encodeURIComponent(ip)); if (raw.includes("{ip}")) return raw.split("{ip}").join(encodeURIComponent(ip)); if (raw.includes("IP_PLACEHOLDER")) return raw.split("IP_PLACEHOLDER").join(encodeURIComponent(ip)); return raw + encodeURIComponent(ip); }
function setLoading(yes) { state.loading = yes; $("refreshBtn").disabled = yes; // 保留最小状态展示,不再输出“数据已更新”等重复文案 $("statusLine").innerHTML = yes ? '<span class="loading"><span class="spinner"></span><span>正在拉取 IP 数据</span></span>' : '<span>就绪</span>'; }
function setSkeleton(on) { ["ipText", "locationText", "ispText", "scoreText"].forEach((id) => { const el = $(id); if (!el) return; el.classList.toggle("skeleton", on); }); }
function updateApiEditor() { $("apiPanel").classList.toggle("show", !!state.apiEditorOpen); $("editApiBtn").textContent = state.apiEditorOpen ? "收起API" : "编辑API"; $("apiInput").value = state.apiTemplate || DEFAULT_RISK_API_TEMPLATE; }
function renderNullState(message) { $("ipText").textContent = "无法获取数据"; $("locationText").textContent = message || "请检查网络、宿主接口或 API 配置"; $("ispText").textContent = "—"; $("scoreText").textContent = "--"; $("scoreText").style.color = "var(--txt)"; $("ring").style.setProperty("--ring-angle", "0deg"); $("ring").style.setProperty("--ring-color", "var(--accent)"); $("riskTag").textContent = "风险等级"; $("chips").innerHTML = '<span class="chip bad">无有效数据</span>'; $("updatedAt").textContent = "--"; }
function render(payload) { if (!payload || !payload.data) { renderNullState(payload && payload.message ? payload.message : ""); return; }
const d = payload.data; const scamScore = Number(d.scamScore); const safeScore = Number.isFinite(scamScore) ? clamp(Math.round(scamScore), 0, 100) : 0; const riskColor = getRiskColor(safeScore);
$("ipText").textContent = safeText(d.ip); $("locationText").textContent = safeText(d.location); $("ispText").textContent = safeText(d.isp); $("scoreText").textContent = Number.isFinite(scamScore) ? String(safeScore) : "--"; $("scoreText").style.color = riskColor; $("ring").style.setProperty("--ring-angle", `${safeScore * 3.6}deg`); $("ring").style.setProperty("--ring-color", riskColor); $("riskTag").textContent = getRiskTag(safeScore);
const chip1Class = d.isHomeBroadband === "是家宽" ? "good" : "warn"; const chip2Class = d.isNativeIP === "原生" ? "good" : "warn"; const chip3Class = safeScore < 20 ? "good" : safeScore <= 50 ? "warn" : "bad";
$("chips").innerHTML = [ `<span class="chip ${chip1Class}">${escapeHTML(d.isHomeBroadband)}</span>`, `<span class="chip ${chip2Class}">${escapeHTML(d.isNativeIP)}</span>`, `<span class="chip ${chip3Class}">风险 ${escapeHTML(String(safeScore))}%</span>` ].join("");
$("updatedAt").textContent = payload.updatedAt ? `更新于 ${payload.updatedAt}` : "--"; }
function setStatusError(message) { state.error = message; $("statusLine").innerHTML = `<span class="error">${escapeHTML(message)}</span>`; }
async function refresh() { if (state.loading) return; setLoading(true); setSkeleton(true); state.error = "";
try { const ipInfo = await getJsonByHttp(IP_API_URL, 10000); if (!ipInfo || ipInfo.status !== "success") { throw new Error(ipInfo && ipInfo.message ? String(ipInfo.message) : "IP 接口返回失败"); }
const ip = safeText(ipInfo.query, ""); if (!ip) throw new Error("IP 为空");
const country = safeText(ipInfo.country, "未知"); const regionName = safeText(ipInfo.regionName, "未知"); const city = safeText(ipInfo.city, ""); const isp = safeText(ipInfo.isp, "未知"); const org = safeText(ipInfo.org, "");
let riskData = null; let riskScore = null; let source = "heuristic";
try { const riskUrl = buildRiskUrl(state.apiTemplate, ip); const text = await getTextByHttp(riskUrl, 12000); try { riskData = JSON.parse(text); } catch (_) { riskData = null; }
const candidate = riskData && typeof riskData === "object" ? (riskData.score ?? riskData.risk ?? riskData.data?.score ?? riskData.data?.risk) : null;
if (candidate !== null && candidate !== undefined && candidate !== "") { const n = Number(candidate); if (!Number.isNaN(n)) { riskScore = clamp(Math.round(n), 0, 100); source = "api"; } } } catch (_) { riskData = null; }
const heuristic = calculateRiskDetails(isp, org, country, regionName, city); if (riskScore === null) { riskScore = heuristic.scamScore; }
const payload = { data: { ip: ip, location: [country, regionName, city].filter(Boolean).join(" "), isp: isp, org: org, scamScore: riskScore, isHomeBroadband: heuristic.isHomeBroadband, isNativeIP: heuristic.isNativeIP }, updatedAt: nowText(), source: source };
// 先更新内存,再刷新界面,再落本地与宿主 state.data = payload.data; state.updatedAt = payload.updatedAt; state.source = source;
await persist(STORE_KEY, payload); render({ data: payload.data, updatedAt: payload.updatedAt, cached: false });
// 保留最小化状态,不显示冗余说明文案 $("statusLine").innerHTML = `<span>就绪</span>`; $("subStatus").textContent = source === "api" ? "API 数据优先。" : "本地规则兜底。"; } catch (err) { const cached = loadStored(STORE_KEY); const msg = err && err.message ? err.message : "未知错误"; state.error = `刷新失败:${msg}`;
if (cached && cached.data) { state.data = cached.data; state.updatedAt = cached.updatedAt || ""; state.source = cached.source || "cache"; render({ data: cached.data, updatedAt: cached.updatedAt || "", cached: true }); setStatusError("刷新失败,已回退到本地缓存"); } else { render({ message: `获取失败:${msg}` }); setStatusError(`刷新失败:${msg}`); } } finally { setLoading(false); setSkeleton(false); } }
async function copyIp() { const ip = state.data && state.data.ip && state.data.ip !== "--" ? state.data.ip : ""; if (!ip) { $("statusLine").innerHTML = `<span class="error">没有可复制的 IP</span>`; return; }
try { if (window.yanm && typeof window.yanm.invoke === "function") { await invokeYanm("clipboard.write", { text: ip }); } else if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(ip); } else { throw new Error("剪贴板不可用"); } $("statusLine").innerHTML = `<span>IP 已复制到剪贴板</span>`; } catch (_) { $("statusLine").innerHTML = `<span class="error">复制失败</span>`; } }
function openApiEditor() { state.apiEditorOpen = !state.apiEditorOpen; updateApiEditor(); if (state.apiEditorOpen) { $("apiInput").focus(); $("apiInput").select(); } }
async function saveApiTemplate(template) { const raw = safeText(template, "").trim(); if (!raw) { $("apiTiny").textContent = "API 不能为空。"; $("apiTiny").style.color = "var(--bad)"; return false; } if (!/^https?:\/\//i.test(raw)) { $("apiTiny").textContent = "请填写以 http:// 或 https:// 开头的 API URL。"; $("apiTiny").style.color = "var(--bad)"; return false; }
state.apiTemplate = raw; state.apiEditorOpen = false; updateApiEditor();
$("apiTiny").textContent = "正在保存…"; $("apiTiny").style.color = "var(--muted2)";
await persist(API_KEY, { template: raw, updatedAt: nowText() });
$("apiTiny").textContent = "已保存到本地缓存与燕幕宿主状态。"; $("apiTiny").style.color = "var(--muted2)"; $("statusLine").innerHTML = `<span>API 已更新,正在重新获取数据</span>`; await refresh(); return true; }
async function resetApiTemplate() { state.apiTemplate = DEFAULT_RISK_API_TEMPLATE; await persist(API_KEY, { template: DEFAULT_RISK_API_TEMPLATE, updatedAt: nowText(), reset: true }); updateApiEditor(); $("apiTiny").textContent = "已恢复默认 API,并同步保存。"; $("apiTiny").style.color = "var(--muted2)"; $("statusLine").innerHTML = `<span>API 已恢复默认</span>`; await refresh(); }
function initFallback() { $("ipText").textContent = "正在获取…"; $("locationText").textContent = "正在获取…"; $("ispText").textContent = "正在获取…"; $("scoreText").textContent = "--"; $("riskTag").textContent = "风险等级"; $("chips").innerHTML = ` <span class="chip">等待数据</span> <span class="chip">等待数据</span> <span class="chip">等待数据</span> `; $("updatedAt").textContent = "--"; $("statusLine").innerHTML = '<span class="loading"><span class="spinner"></span><span>正在拉取 IP 数据</span></span>'; $("subStatus").textContent = "宿主请求失败时会回退到本地缓存。"; updateApiEditor(); }
function bindUI() { $("refreshBtn").addEventListener("click", refresh); $("copyBtn").addEventListener("click", copyIp); $("editApiBtn").addEventListener("click", openApiEditor); $("saveApiBtn").addEventListener("click", () => saveApiTemplate($("apiInput").value)); $("resetApiBtn").addEventListener("click", resetApiTemplate);
$("apiInput").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); saveApiTemplate($("apiInput").value); } else if (e.key === "Escape") { e.preventDefault(); state.apiEditorOpen = false; updateApiEditor(); } }); }
async function hydrateFromLocal() { const localApi = loadStored(API_KEY); if (localApi && typeof localApi === "object" && localApi.template) { state.apiTemplate = safeText(localApi.template, DEFAULT_RISK_API_TEMPLATE); } else { state.apiTemplate = DEFAULT_RISK_API_TEMPLATE; }
const localCache = loadStored(STORE_KEY); if (localCache && localCache.data) { state.data = localCache.data; state.updatedAt = localCache.updatedAt || ""; state.source = localCache.source || "cache"; render({ data: localCache.data, updatedAt: localCache.updatedAt || "", cached: true }); }
updateApiEditor(); }
async function hydrateFromHost() { try { const hostApi = await readHost(API_KEY); if (hostApi && hostApi.template) { state.apiTemplate = safeText(hostApi.template, state.apiTemplate || DEFAULT_RISK_API_TEMPLATE); }
const hostCache = await readHost(STORE_KEY); if (hostCache && hostCache.data) { state.data = hostCache.data; state.updatedAt = hostCache.updatedAt || state.updatedAt || ""; state.source = hostCache.source || state.source || "cache"; render({ data: hostCache.data, updatedAt: hostCache.updatedAt || state.updatedAt || "", cached: true }); }
updateApiEditor(); await persist(API_KEY, { template: state.apiTemplate, updatedAt: nowText() }); if (hostCache && hostCache.data) { await persist(STORE_KEY, hostCache); } } catch (_) {} }
async function waitForYanmReady(timeoutMs = 12000) { const start = Date.now(); return new Promise((resolve) => { const tick = () => { if (window.yanm && typeof window.yanm.invoke === "function") { resolve(true); return; } if (Date.now() - start >= timeoutMs) { resolve(false); return; } setTimeout(tick, 120); }; tick(); }); }
async function boot() { initFallback(); bindUI();
// 先读本地缓存,再尝试宿主 await hydrateFromLocal(); state.hostReady = await waitForYanmReady();
// 宿主存在时,回读并刷新 await hydrateFromHost();
if (state.hostReady) { await refresh(); } else { $("statusLine").innerHTML = `<span class="error">宿主未就绪,当前仅显示缓存</span>`; $("subStatus").textContent = "请确认燕幕宿主已注入 window.yanm。"; } }
if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot, { once: true }); } else { boot(); }
window.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "r") { e.preventDefault(); refresh(); } if (e.key === "F5") { e.preventDefault(); refresh(); } }); })(); </script></body></html>