修复了周额度计算可能偏高的问题。
旧版脚本只按“日期”判断本周周期,没有保留具体时间。比如真实周期是中午 12 点开始,旧版会当成当天 0 点开始,导致把周期开始前的用量也算进本周。
新版改为保留完整周期时间,并对周期开始当天做比例估算,避免把不属于本周期的用量算进去。
// ==UserScript==
// @name Codex Quota Compass (Visual Edition)
// @namespace http://tampermonkey.net/
// @version 1.9
// @description 人性化展示 Codex 配额
// @author Jun Zhao, Canxin
// @match https://chatgpt.com/codex/cloud/settings/analytics*
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
if (window.__codexQuotaCompassInjected) return;
window.__codexQuotaCompassInjected = true;
const CONFIG = {
USD_PER_CREDIT: 40 / 1000,
HISTORY_DAYS: 30,
TARGET_WINDOW_SECONDS: 7 * 24 * 60 * 60,
DAY_MS: 24 * 60 * 60 * 1000,
// 重要:原脚本用 toISOString(),因此这里默认继续按 UTC 日期与后端 daily bucket 对齐。
// 如果你确认后端 daily bucket 是浏览器本地日期,可以改为 false。
USE_UTC_DAY: true,
EPS: 1e-9,
};
const HTML_ESCAPE = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
const escapeHtml = (value) =>
String(value ?? "").replace(/[&<>"']/g, (ch) => HTML_ESCAPE[ch]);
const n = (value) => {
if (typeof value === "string") value = value.replace(/,/g, "").trim();
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const clamp = (value, min = 0, max = 1) =>
Math.min(max, Math.max(min, n(value)));
const trimFixed = (value, digits = 2) => {
const str = n(value).toFixed(digits);
return str.replace(/\.?0+$/, "");
};
const fmtNum = (value) => {
const num = n(value);
const abs = Math.abs(num);
const sign = num < 0 ? "-" : "";
if (abs >= 1e9) return sign + trimFixed(abs / 1e9, 2) + "B";
if (abs >= 1e6) return sign + trimFixed(abs / 1e6, 2) + "M";
if (abs >= 1e3) return sign + trimFixed(abs / 1e3, 2) + "K";
return num.toLocaleString();
};
const fmtCredits = (value, digits = 3) => n(value).toFixed(digits);
const fmtTurns = (value) => {
const num = n(value);
return Math.abs(num - Math.round(num)) < 1e-9
? String(Math.round(num))
: num.toFixed(1);
};
const tokenTotal = (obj = {}) => {
const total = n(obj.text_total_tokens);
const parts =
n(obj.cached_text_input_tokens) +
n(obj.uncached_text_input_tokens) +
n(obj.text_output_tokens);
return total > 0 ? total : parts;
};
const pad2 = (value) => String(value).padStart(2, "0");
const dateKeyFromMs = (ms) => {
const d = new Date(ms);
if (!Number.isFinite(d.getTime())) return "";
if (CONFIG.USE_UTC_DAY) return d.toISOString().slice(0, 10);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
};
const dayStartMs = (dateKey) => {
const [year, month, day] = String(dateKey || "")
.slice(0, 10)
.split("-")
.map(Number);
if (!year || !month || !day) return NaN;
return CONFIG.USE_UTC_DAY
? Date.UTC(year, month - 1, day)
: new Date(year, month - 1, day).getTime();
};
const formatDateTime = (ms) => {
const d = new Date(ms);
if (!Number.isFinite(d.getTime())) return "未知";
return d.toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
};
const formatDuration = (seconds) => {
const s = n(seconds);
if (!s) return "当前周期";
const days = s / 86400;
if (days >= 1) {
const rounded = Math.round(days);
return Math.abs(days - rounded) < 1e-6
? `${rounded} 天周期`
: `${days.toFixed(1)} 天周期`;
}
const hours = s / 3600;
return `${trimFixed(hours, 1)} 小时周期`;
};
const formatPercentFromRatio = (ratio) => {
const pct = clamp(ratio) * 100;
return `${pct >= 10 ? pct.toFixed(1) : pct.toFixed(2)}%`;
};
const asArray = (value) => (Array.isArray(value) ? value : []);
GM_addStyle(`
#codex-compass-root {
position: fixed; top: 5%; left: 50%; transform: translateX(-50%);
width: 760px; max-width: calc(100vw - 32px); max-height: 90vh; background: #fff;
border-radius: 12px; box-shadow: 0 10px 50px rgba(0,0,0,0.3);
z-index: 10001; padding: 24px; display: none;
flex-direction: column; border: 1px solid #e5e5e5; color: #333;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
overflow: hidden;
}
.compass-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 15px; }
.compass-title { font-size: 18px; font-weight: 600; }
.compass-subtitle { font-size: 12px; color: #777; margin-top: 3px; line-height: 1.4; }
.compass-close { cursor: pointer; font-size: 28px; color: #999; line-height: 1; user-select: none; }
.compass-close:hover { color: #333; }
.compass-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; }
.compass-card { background: #f9f9f9; padding: 12px; border-radius: 8px; border: 1px solid #eee; min-width: 0; }
.compass-card.highlight { background: #eefaf5; border-color: #d1f2e1; }
.card-label { font-size: 12px; color: #666; margin-bottom: 4px; }
.card-value { font-size: 15px; font-weight: bold; color: #10a37f; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.compass-note {
font-size: 12px; color: #6b7280; background: #f8fafc; border: 1px solid #e5e7eb;
padding: 8px 10px; border-radius: 8px; margin-bottom: 14px; line-height: 1.45;
}
.table-container {
max-height: 220px; overflow-y: auto; border: 1px solid #eee;
border-radius: 6px; margin-bottom: 15px;
}
.compass-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.compass-table thead { position: sticky; top: 0; background: #f5f5f5; z-index: 1; }
.compass-table th { text-align: left; padding: 8px; border-bottom: 1px solid #eee; color: #666; white-space: nowrap; }
.compass-table td { padding: 8px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
.compass-footer-row { background: #fafafa; font-weight: bold; position: sticky; bottom: 0; border-top: 2px solid #eee; }
.estimated-row { color: #374151; }
.estimated-mark { color: #d97706; font-weight: 600; margin-left: 3px; }
#codex-compass-btn {
position: fixed; bottom: 20px; right: 20px; z-index: 10000;
padding: 10px 20px; background: #10a37f; color: white;
border: none; border-radius: 8px; cursor: pointer; font-weight: 600;
box-shadow: 0 4px 20px rgba(0,0,0,0.16);
}
#codex-compass-btn:disabled {
opacity: 0.72; cursor: wait;
}
@media (max-width: 720px) {
#codex-compass-root { top: 16px; padding: 16px; }
.compass-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
`);
async function getAccessToken() {
const bootstrapData =
document.getElementById("client-bootstrap")?.textContent || "";
const jwtCandidates =
bootstrapData.match(/eyJ[\w-]*\.[\w-]+\.[\w-]+/g) ||
bootstrapData.match(/[\w-]{30,}\.[\w-]{30,}\.[\w-]{30,}/g) ||
[];
if (jwtCandidates.length > 0) return jwtCandidates[0];
try {
const res = await fetch("/api/auth/session", {
credentials: "include",
cache: "no-store",
});
if (!res.ok) return null;
const session = await res.json();
return (
session?.accessToken ||
session?.access_token ||
session?.token ||
null
);
} catch {
return null;
}
}
async function apiGet(path, token) {
const res = await fetch(path, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
credentials: "include",
cache: "no-store",
});
const text = await res.text();
let json = null;
if (text) {
try {
json = JSON.parse(text);
} catch {
// 交给下面的 res.ok 分支给出更清楚的报错。
}
}
if (!res.ok) {
const detail =
json?.detail ||
json?.message ||
(typeof json?.error === "string" ? json.error : json?.error?.message) ||
text.slice(0, 200);
throw new Error(
`${res.status} ${res.statusText}${detail ? `: ${detail}` : ""}`,
);
}
if (json === null) throw new Error("接口返回的不是 JSON。");
return json;
}
const rateLimitWindows = (rateLimit = {}) =>
Object.entries(rateLimit)
.filter(
([key, value]) =>
key.endsWith("_window") && value && typeof value === "object",
)
.map(([key, value]) => ({
...value,
_window_name: key,
}));
const pickQuotaWindow = (rateLimit = {}) => {
const windows = rateLimitWindows(rateLimit);
if (windows.length === 0) return null;
const withDuration = windows.filter((w) => n(w.limit_window_seconds) > 0);
if (withDuration.length > 0) {
return [...withDuration].sort(
(a, b) =>
Math.abs(n(a.limit_window_seconds) - CONFIG.TARGET_WINDOW_SECONDS) -
Math.abs(n(b.limit_window_seconds) - CONFIG.TARGET_WINDOW_SECONDS),
)[0];
}
if (rateLimit.secondary_window) {
return { ...rateLimit.secondary_window, _window_name: "secondary_window" };
}
return windows[0];
};
const toEpochMs = (secondsOrMs) => {
const value = n(secondsOrMs);
if (!value) return NaN;
return value > 1e12 ? value : value * 1000;
};
const cycleRangeFromWindow = (quotaWindow) => {
const resetMs = toEpochMs(quotaWindow?.reset_at);
const durationMs = n(quotaWindow?.limit_window_seconds) * 1000;
const nowMs = Date.now();
if (Number.isFinite(resetMs) && durationMs > 0) {
return {
cycleStartMs: resetMs - durationMs,
cycleEndMs: resetMs,
};
}
return {
cycleStartMs: nowMs - CONFIG.TARGET_WINDOW_SECONDS * 1000,
cycleEndMs: nowMs + CONFIG.TARGET_WINDOW_SECONDS * 1000,
};
};
const usedRatioFromWindow = (quotaWindow) => {
// 字段名是 used_percent,因此按 0-100 的百分比处理;避免把 1% 误判成 100%。
const rawPercent = n(quotaWindow?.used_percent);
if (rawPercent <= 0) return 0;
return clamp(rawPercent / 100, 0, 1);
};
const scaleTotals = (totals = {}, ratio = 1) => {
const out = { ...totals };
const numericKeys = [
"credits",
"turns",
"text_total_tokens",
"cached_text_input_tokens",
"uncached_text_input_tokens",
"text_output_tokens",
];
numericKeys.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(totals, key)) {
out[key] = n(totals[key]) * ratio;
}
});
return out;
};
const makeScaledRow = (item, ratio, displayDate, sortOffset = 0) => ({
...item,
totals: scaleTotals(item.totals || {}, ratio),
_displayDate: displayDate,
_dateKey: String(item?.date || "").slice(0, 10),
_sortTs: dayStartMs(item?.date) + sortOffset,
_ratio: ratio,
_estimated: ratio > CONFIG.EPS && ratio < 1 - CONFIG.EPS,
});
const splitDailyBuckets = (
dailyList,
cycleStartMs,
cycleEndMs,
nowMs = Date.now(),
) => {
const currentCycleList = [];
const historyList = [];
asArray(dailyList).forEach((item) => {
const dateKey = String(item?.date || "").slice(0, 10);
const bucketStart = dayStartMs(dateKey);
if (!Number.isFinite(bucketStart)) return;
const bucketEnd = bucketStart + CONFIG.DAY_MS;
// daily 接口里“今天”的 bucket 通常只包含目前已经发生的用量,
// 所以今天不能按 24 小时完整天去折算,否则会把今天的用量低估。
const observedStart = bucketStart;
const observedEnd = Math.min(bucketEnd, nowMs);
const observedMs = observedEnd - observedStart;
// 跳过未来日期或空 bucket。
if (observedMs <= 0) return;
const currentOverlap = Math.max(
0,
Math.min(observedEnd, cycleEndMs, nowMs) -
Math.max(observedStart, cycleStartMs),
);
const currentRatio = clamp(currentOverlap / observedMs);
const outsideRatio = clamp(1 - currentRatio);
if (currentRatio > CONFIG.EPS) {
const label =
currentRatio < 1 - CONFIG.EPS
? `${dateKey}(周期内≈${formatPercentFromRatio(currentRatio)})`
: dateKey;
currentCycleList.push(makeScaledRow(item, currentRatio, label, 1));
}
const hasOutsidePart =
observedStart < cycleStartMs || observedEnd > cycleEndMs;
if (hasOutsidePart && outsideRatio > CONFIG.EPS) {
const outsideLabel =
observedStart < cycleStartMs ? "周期前" : "周期后";
const label =
outsideRatio < 1 - CONFIG.EPS
? `${dateKey}(${outsideLabel}≈${formatPercentFromRatio(
outsideRatio,
)})`
: dateKey;
historyList.push(makeScaledRow(item, outsideRatio, label, 0));
}
});
const byTime = (a, b) => a._sortTs - b._sortTs;
return {
currentCycleList: currentCycleList.sort(byTime),
historyList: historyList.sort(byTime),
};
};
const getStats = (list) => {
const stats = list.reduce(
(acc, row) => {
acc.credits += n(row.totals?.credits);
acc.turns += n(row.totals?.turns);
acc.tokens += tokenTotal(row.totals);
return acc;
},
{ credits: 0, turns: 0, tokens: 0 },
);
return {
...stats,
usd: stats.credits * CONFIG.USD_PER_CREDIT,
};
};
const renderTable = (list, stats) => {
const rows = [...list].sort((a, b) => b._sortTs - a._sortTs);
return `
<div class="table-container">
<table class="compass-table">
<thead>
<tr><th>日期</th><th>Credits</th><th>Tokens</th><th>金额</th><th>轮数</th></tr>
</thead>
<tbody>
${
rows.length === 0
? `<tr><td colspan="5" style="color:#777; text-align:center; padding:14px;">暂无数据</td></tr>`
: rows
.map((row) => {
const estimatedTitle = row._estimated
? "该行由按天聚合数据按时间占比估算,无法精确到真实使用时刻。"
: "";
return `
<tr class="${row._estimated ? "estimated-row" : ""}">
<td title="${escapeHtml(estimatedTitle)}">
${escapeHtml(row._displayDate || row.date)}
${
row._estimated
? `<span class="estimated-mark">*</span>`
: ""
}
</td>
<td style="font-family:monospace">${fmtCredits(row.totals?.credits)}</td>
<td style="font-family:monospace">${fmtNum(tokenTotal(row.totals))}</td>
<td>$ ${(n(row.totals?.credits) * CONFIG.USD_PER_CREDIT).toFixed(2)}</td>
<td>${fmtTurns(row.totals?.turns)}</td>
</tr>
`;
})
.join("")
}
</tbody>
<tfoot>
<tr class="compass-footer-row">
<td>合计</td>
<td>${fmtCredits(stats.credits)}</td>
<td style="font-family:monospace">${fmtNum(stats.tokens)}</td>
<td style="color:#10a37f">$ ${stats.usd.toFixed(2)}</td>
<td>${fmtTurns(stats.turns)}</td>
</tr>
</tfoot>
</table>
</div>
`;
};
const showPanel = (data) => {
const root = document.getElementById("codex-compass-root");
if (!root) return;
const { quotaWindow, dailyList, cycleStartMs, cycleEndMs, nowMs } = data;
const { currentCycleList, historyList } = splitDailyBuckets(
dailyList,
cycleStartMs,
cycleEndMs,
nowMs,
);
const currentStats = getStats(currentCycleList);
const historyStats = getStats(historyList);
const usedRatio = usedRatioFromWindow(quotaWindow);
const rawUsedPercent = n(quotaWindow?.used_percent);
const usedPercentText =
rawUsedPercent > 0 ? `${trimFixed(rawUsedPercent, 2)}%` : "未知";
const estCredits =
usedRatio > CONFIG.EPS ? currentStats.credits / usedRatio : NaN;
const hasEstCredits = Number.isFinite(estCredits) && estCredits > 0;
const estCreditsText = hasEstCredits ? estCredits.toFixed(1) : "未知";
const estUsdText = hasEstCredits
? `$ ${(estCredits * CONFIG.USD_PER_CREDIT).toFixed(2)}`
: "未知";
const windowName = quotaWindow?._window_name || "unknown_window";
const periodName = formatDuration(quotaWindow?.limit_window_seconds);
const estimatedRows = [...currentCycleList, ...historyList].some(
(row) => row._estimated,
);
let historyRangeTitle = "⏳ 历史记录(本周期外)";
if (historyList.length > 0) {
const sortedHistory = [...historyList].sort((a, b) => a._sortTs - b._sortTs);
const startStr = sortedHistory[0]._dateKey || sortedHistory[0].date;
const endStr =
sortedHistory[sortedHistory.length - 1]._dateKey ||
sortedHistory[sortedHistory.length - 1].date;
historyRangeTitle = `⏳ 历史记录(本周期外 ${startStr} 至 ${endStr})`;
}
root.innerHTML = `
<div class="compass-header">
<div>
<div class="compass-title">📊 Codex 配额深度分析 (V1.9)</div>
<div class="compass-subtitle">
${escapeHtml(periodName)} · ${escapeHtml(windowName)}
· ${escapeHtml(formatDateTime(cycleStartMs))} → ${escapeHtml(
formatDateTime(cycleEndMs),
)}
</div>
</div>
<div class="compass-close" id="compass-close-btn">×</div>
</div>
<div class="compass-grid">
<div class="compass-card">
<div class="card-label">已用比例</div>
<div class="card-value">${escapeHtml(usedPercentText)}</div>
</div>
<div class="compass-card">
<div class="card-label">本周期已用</div>
<div class="card-value">${currentStats.credits.toFixed(1)} <span style="font-size:10px; font-weight:normal">Credits</span></div>
</div>
<div class="compass-card highlight">
<div class="card-label">推算总额</div>
<div class="card-value">${escapeHtml(estCreditsText)} <span style="font-size:10px; font-weight:normal">Credits</span></div>
</div>
<div class="compass-card">
<div class="card-label">周期价值(估算)</div>
<div class="card-value">${escapeHtml(estUsdText)}</div>
</div>
</div>
<div class="compass-note">
${
estimatedRows
? `带 <b>*</b> 的边界日期来自 daily 聚合数据的时间比例估算;因为接口不是按请求/小时返回,所以无法知道周期开始前后真实各用了多少。`
: `当前周期没有跨 daily bucket 的边界折算;若后端只返回 daily 聚合,边界日仍可能存在不可见的细粒度误差。`
}
</div>
<div style="font-weight:600; margin-bottom:8px; font-size:13px;">
📅 本周期明细(始于 ${escapeHtml(formatDateTime(cycleStartMs))})
</div>
${renderTable(currentCycleList, currentStats)}
${
historyList.length > 0
? `
<div style="font-weight:600; margin-bottom:8px; font-size:13px; color: #666; margin-top: 5px;">
${escapeHtml(historyRangeTitle)}
</div>
${renderTable(historyList, historyStats)}
`
: ""
}
`;
root.style.display = "flex";
document.getElementById("compass-close-btn").onclick = () => {
root.style.display = "none";
};
};
const run = async () => {
const btn = document.getElementById("codex-compass-btn");
if (!btn || btn.disabled) return;
btn.disabled = true;
btn.innerText = "分析中...";
try {
const token = await getAccessToken();
if (!token) {
alert("令牌获取失败,请确保已登录,或刷新页面后重试。");
return;
}
const usage = await apiGet("/backend-api/wham/usage", token);
const quotaWindow = pickQuotaWindow(usage?.rate_limit);
const { cycleStartMs, cycleEndMs } = cycleRangeFromWindow(quotaWindow);
const nowMs = Date.now();
const historyStartMs = nowMs - CONFIG.HISTORY_DAYS * CONFIG.DAY_MS;
const queryStartMs = Math.min(historyStartMs, cycleStartMs - CONFIG.DAY_MS);
const startDate = dateKeyFromMs(queryStartMs);
// 多取一天,兼容 end_date 是 exclusive 的实现;splitDailyBuckets 会跳过未来日期。
const endDate = dateKeyFromMs(nowMs + CONFIG.DAY_MS);
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate,
group_by: "day",
});
const dailyData = await apiGet(
`/backend-api/wham/analytics/daily-workspace-usage-counts?${params.toString()}`,
token,
);
showPanel({
quotaWindow,
dailyList: asArray(dailyData?.data),
cycleStartMs,
cycleEndMs,
nowMs,
});
} catch (e) {
console.error("[Codex Quota Compass]", e);
alert("错误: " + (e?.message || String(e)));
} finally {
btn.disabled = false;
btn.innerText = "📊 运行用量分析";
}
};
const mount = () => {
if (document.getElementById("codex-compass-btn")) return;
const btn = document.createElement("button");
btn.id = "codex-compass-btn";
btn.innerText = "📊 运行用量分析";
btn.onclick = run;
document.body.appendChild(btn);
const root = document.createElement("div");
root.id = "codex-compass-root";
document.body.appendChild(root);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mount, { once: true });
} else {
mount();
}
})();











