小组件 IP查询

经验创意 · 10 次浏览
我的梦想捐钱修路建学校 创建于 3小时47分钟前

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) => ({
          "&":"&amp;",
          "<":"&lt;",
          ">":"&gt;",
          "\"":"&quot;",
          "'":"&#39;"
        }[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>


回复内容
暂无回复
回复主贴