Codex次数统计

在 zetaloop 佬的思路基础上,简单包了个 UI(双击徽章切换至仅小圆点模式),详见 https://linux.do/t/topic/576007/44

// ==UserScript==
// @name         Codex次数统计
// @namespace    https://github.com/your-username/codex-counter
// @version      1.2.0
// @description  在 zetaloop 佬的思路基础上,简单包了个 UI(双击徽章切换至仅小圆点模式),详见 https://linux.do/t/topic/576007/44
// @author       schweigen
// @license      MIT
// @homepage     https://linux.do/t/topic/576007/44
// @match        *://chatgpt.com/codex*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
  "use strict";

  // ====== state ======
  const LS_KEY_COLLAPSED = "codex_counter_collapsed";
  let codexData = {
    limit: null,
    used: null,
    remaining: null,
    resetTime: null, // epoch ms
  };
  let showResetOnHover = false;

  // ====== utils ======
  const getCollapsed = () => {
    try { return localStorage.getItem(LS_KEY_COLLAPSED) === "1"; } catch { return false; }
  };
  const setCollapsed = (v) => {
    try { localStorage.setItem(LS_KEY_COLLAPSED, v ? "1" : "0"); } catch {}
  };
  const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
  const fmtMMSS = (ms) => {
    if (ms == null || ms <= 0) return "--:--";
    const m = Math.floor(ms / 60000);
    const s = Math.floor((ms % 60000) / 1000);
    return `${m}:${String(s).padStart(2, "0")}`;
  };

  // ====== UI ======
  function createCodexDisplay() {
    if (!document.body) { requestAnimationFrame(createCodexDisplay); return; }
    if (document.getElementById("codex-counter")) return;

    const box = document.createElement("div");
    box.id = "codex-counter";
    // 超小徽章:一行、极少留白
    Object.assign(box.style, {
      position: "fixed",
      top: "8px",
      right: "8px",
      padding: "2px 6px",
      background: "rgba(0,0,0,0.6)",
      color: "#fff",
      fontSize: "10px",
      lineHeight: "1",
      borderRadius: "9999px",
      zIndex: "2147483647",
      fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif",
      display: "none", // 初始隐藏
      userSelect: "none",
      WebkitFontSmoothing: "antialiased",
      backdropFilter: "blur(2px)",
      cursor: "default",
    });

    const row = document.createElement("div");
    Object.assign(row.style, {
      display: "inline-flex",
      alignItems: "center",
      gap: "4px",
      pointerEvents: "auto",
    });

    const dot = document.createElement("span");
    dot.id = "codex-dot";
    Object.assign(dot.style, {
      width: "6px",
      height: "6px",
      borderRadius: "50%",
      display: "inline-block",
      background: "#6ee7b7", // 初始绿
      flex: "0 0 auto",
    });

    const usage = document.createElement("span");
    usage.id = "codex-usage";
    usage.textContent = "--/--";
    usage.style.fontWeight = "600";

    row.appendChild(dot);
    row.appendChild(usage);
    box.appendChild(row);

    // 双击切换“仅小圆点”超微型模式
    box.addEventListener("dblclick", () => {
      const collapsed = !getCollapsed();
      setCollapsed(collapsed);
      applyCollapsedState(box, collapsed);
    });

    // 悬停时切换显示倒计时
    box.addEventListener("mouseenter", () => {
      showResetOnHover = true;
      renderUsageText();
    });
    box.addEventListener("mouseleave", () => {
      showResetOnHover = false;
      renderUsageText();
    });

    document.body.appendChild(box);
    applyCollapsedState(box, getCollapsed());
  }

  function applyCollapsedState(box, collapsed) {
    const usage = box.querySelector("#codex-usage");
    const dot = box.querySelector("#codex-dot");
    if (!usage || !dot) return;

    if (collapsed) {
      usage.style.display = "none";
      box.style.padding = "0";
      box.style.width = "10px";
      box.style.height = "10px";
      box.style.top = "10px";
      box.style.right = "10px";
      Object.assign(dot.style, { width: "10px", height: "10px" });
    } else {
      usage.style.display = "";
      box.style.padding = "2px 6px";
      box.style.width = "auto";
      box.style.height = "auto";
      Object.assign(dot.style, { width: "6px", height: "6px" });
    }
  }

  // 初始化显示
  createCodexDisplay();

  // 防止被删
  const observer = new MutationObserver(() => {
    if (!document.getElementById("codex-counter")) {
      createCodexDisplay();
    }
  });
  function startObserver() {
    if (!document.body) { requestAnimationFrame(startObserver); return; }
    observer.observe(document.body, { childList: true, subtree: true });
  }
  startObserver();

  // ====== render / title ======
  function renderUsageText() {
    const usageEl = document.getElementById("codex-usage");
    if (!usageEl) return;

    if (typeof codexData.limit !== "number") {
      usageEl.textContent = "--/--";
      return;
    }

    if (showResetOnHover) {
      const remainMs = Math.max(0, (codexData.resetTime || 0) - Date.now());
      usageEl.textContent = `重置 ${fmtMMSS(remainMs)}`;
    } else {
      usageEl.textContent = `${codexData.used}/${codexData.limit}`;
    }
  }

  function updateBadge(limit, remaining, resetsAfterSeconds) {
    const box = document.getElementById("codex-counter");
    const dot = document.getElementById("codex-dot");
    if (!box || !dot) return;

    if (typeof limit !== "number" || typeof remaining !== "number") {
      box.style.display = "none";
      return;
    }

    // 数据
    codexData.limit = limit;
    codexData.remaining = remaining;
    codexData.used = clamp(limit - remaining, 0, limit);
    codexData.resetTime = Date.now() + (Number(resetsAfterSeconds) || 0) * 1000;

    // 显示
    if (location.pathname.startsWith("/codex")) {
      box.style.display = "inline-block";
    } else {
      box.style.display = "none";
    }

    // 使用文本
    renderUsageText();

    // 状态点颜色:<70% 绿,70~90% 橙,>=90% 红
    const pct = (codexData.used / (codexData.limit || 1)) * 100;
    if (pct >= 90) {
      dot.style.background = "#fca5a5"; // 红
    } else if (pct >= 70) {
      dot.style.background = "#fde68a"; // 橙
    } else {
      dot.style.background = "#6ee7b7"; // 绿
    }

    // 原生 tooltip(悬停即见详细信息)
    updateTooltipTitle();
  }

  function updateTooltipTitle() {
    const box = document.getElementById("codex-counter");
    const usageEl = document.getElementById("codex-usage");
    const dot = document.getElementById("codex-dot");
    if (!box) return;

    const targets = [box, usageEl, dot].filter(Boolean);
    if (codexData.limit == null) {
      targets.forEach((el) => el.removeAttribute("title"));
      return;
    }

    const remainMs = Math.max(0, (codexData.resetTime || 0) - Date.now());
    const lines = [
      "Codex 使用统计",
      `使用:${codexData.used}/${codexData.limit}`,
      `剩余:${codexData.remaining}`,
      `重置倒计时:${fmtMMSS(remainMs)}`
    ];
    const tooltip = lines.join("\n");
    targets.forEach((el) => { el.title = tooltip; });
  }

  // 每秒刷新 tooltip 的倒计时,同时在悬停时刷新显示
  setInterval(() => {
    updateTooltipTitle();
    if (showResetOnHover) {
      renderUsageText();
    }
  }, 1000);

  // ====== fetch 拦截 ======
  const originalFetch = window.fetch;
  window.fetch = async function (resource, options = {}) {
    const res = await originalFetch(resource, options);

    try {
      const url = typeof resource === "string" ? resource : resource?.url || "";
      const method = (typeof resource === "object" && resource?.method) ? resource.method : (options?.method || "GET");

      if (
        url.includes("/backend-api/wham/tasks/rate_limit") &&
        String(method).toUpperCase() === "GET" &&
        res.ok
      ) {
        // 读取文本并克隆响应
        const text = await res.text();
        // 尝试解析
        try {
          const data = JSON.parse(text);
          if (location.pathname.startsWith("/codex")) {
            updateBadge(
              data.limit,
              (data.remaining ?? 0) + 1, // 与原脚本一致
              data.resets_after
            );
          }
        } catch (e) {
          // 忽略解析错误,原样返回
        }
        return new Response(text, {
          status: res.status,
          statusText: res.statusText,
          headers: res.headers,
        });
      }
    } catch (e) {
      // 静默失败,继续返回原响应
    }

    return res;
  };

  // ====== 路由切换时隐显 ======
  let currentPath = location.pathname;
  setInterval(() => {
    if (location.pathname !== currentPath) {
      currentPath = location.pathname;
      const box = document.getElementById("codex-counter");
      if (box) {
        if (currentPath.startsWith("/codex") && codexData.limit !== null) {
          box.style.display = "inline-block";
        } else {
          box.style.display = "none";
        }
      }
    }
  }, 1000);
})();