// ==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);
})();