分享一个官号 Codex 用量可视化油猴脚本
木
2026-05-29 17:08
1
最近写了一个用于 ChatGPT Codex analytics 页面的油猴脚本,致谢参考脚本:完全准确查询你的GPT订阅周限的方法
插件地址:
主要功能:
- 在 Codex analytics 页面直接显示 5 小时 / 7 天窗口用量
- 显示已用百分比、剩余比例、下次重置时间
- 支持上次重置至今、本月初至今、近 30 天统计
- 支持每日 Token 明细和客户端汇总
- Token 单位支持原始值、M、亿切换
- 支持表格 CSV 复制
- 面板可折叠、可调整大小,也支持全屏查看宽表格
- 主题配色会跟随 ChatGPT 官网浅色/深色模式
- 脚本不会展示或保存 accessToken / Cookie,只使用当前登录会话请求官方 analytics 接口
适用页面:
https://chatgpt.com/codex/cloud/settings/analytics
如果你也经常看 Codex 用量,多人共用一个官号没经过sub2、cpa2中转站想查看具体总用量,或者觉得官方 analytics 页面信息不够直观,可以试试看。


Tips:claude桌宠是codex的宠物
最新回复 (9)
-
zding 05-29 17:182楼
天才程序员要死了,熬不到重置 -
木 楼主 05-29 17:243楼我有预感还会重置 ^-^ 4.8出来了应该不会一个屁也不放
-
An3ingLora 05-29 17:254楼
为啥我没显示?是 team 的问题吗? -
yuhuangxian 05-29 17:305楼这个如果可以直接用在chatgpt.com聊天页面就好了,我自用的号一般就手动访问cloud/settings/analytics看。
-
lueluelue 05-29 17:316楼凌晨 1 点感觉有可能会重置,毕竟有一个 Codex 大更新
-
An3ingLora 06-01 09:117楼佬我看了下 console,除了一些 i18n 报错就剩这个:

-
BreakALeg 06-07 10:218楼针对佬的脚本让gpt优化了下,
主要修改:
- 版本改为
0.1.9-fixed - 移除第三方自动更新地址,防止修复版被远程版本覆盖
- 优先使用 Cookie 鉴权,仅在 401 时读取 Access Token
- 严格校验额度窗口字段,避免错误查询到 1970 年
- 三个 analytics 请求改为并发
- 刷新时取消旧请求,避免旧数据覆盖新数据
- 低于 1% 使用率时不再输出不可靠的周额度估算
- 修复透明背景导致的主题误判
- 增加
localStorage、剪贴板和 HTTP 错误处理 - HTTP 错误不再把服务器响应正文直接显示在页面上
// ==UserScript==
// @name Codex Quota Compass UI - Fixed
// @name:zh-CN Codex 用量指南针 UI - 修复版
// @namespace https://chatgpt.com/codex
// @version 0.2.0
// @description 显示 Codex 用量、重置时间、每日统计和客户端汇总
// @author creamtea47 / fixed
// @license MIT
// @match https://chatgpt.com/*
// @run-at document-idle
// @grant GM_addStyle
// ==/UserScript==
(() => {
'use strict';
const CONFIG = Object.freeze({
DATE_BUCKET_MODE: 'utc',
USD_PER_CREDIT: 40 / 1000,
ROLLING_DAYS: 30,
USAGE_PATH: '/backend-api/wham/usage',
DAILY_USAGE_PATH:
'/backend-api/wham/analytics/daily-workspace-usage-counts',
});
const ROOT_ID = 'codex-quota-compass-ui';
const VERSION = '0.2.0';
const KEYS = Object.freeze({
collapsed: `${ROOT_ID}:collapsed`,
fullscreen: `${ROOT_ID}:fullscreen`,
position: `${ROOT_ID}:position`,
tokenUnit: `${ROOT_ID}:token-unit`,
});
const DAY_MS = 86_400_000;
let activeController = null;
let requestSerial = 0;
class HttpError extends Error {
constructor(response, path) {
super(`HTTP ${response.status} ${response.statusText}`);
this.name = 'HttpError';
this.status = response.status;
this.path = path;
}
}
function safeGet(key) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function safeSet(key, value) {
try {
localStorage.setItem(key, value);
} catch {
// UI 偏好保存失败不影响主功能。
}
}
function toNumber(value, fallback = 0) {
if (value === null || value === undefined || value === '') {
return fallback;
}
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function requireNumber(value, field) {
if (value === null || value === undefined || value === '') {
throw new Error(`接口字段 ${field} 缺失`);
}
const number = Number(value);
if (!Number.isFinite(number)) {
throw new Error(`接口字段 ${field} 不是有效数字`);
}
return number;
}
function round(value, digits = 2) {
return Number(toNumber(value).toFixed(digits));
}
function clamp(value, min, max) {
if (!Number.isFinite(value)) return min;
return Math.min(Math.max(value, min), Math.max(min, max));
}
function escapeHtml(value) {
return String(value ?? '').replace(
/[&<>"']/g,
character =>
({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
})[character]
);
}
function pad2(value) {
return String(value).padStart(2, '0');
}
function formatInteger(value) {
return new Intl.NumberFormat().format(toNumber(value));
}
function formatNumber(value, digits = 2) {
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: digits,
}).format(toNumber(value));
}
function formatOptional(value, digits = 2) {
return Number.isFinite(value) ? formatNumber(value, digits) : '—';
}
function formatPercent(value) {
return `${formatNumber(value, 2)}%`;
}
function formatLocal(ms) {
const date = new Date(ms);
return Number.isFinite(date.getTime()) ? date.toLocaleString() : '—';
}
function formatUtc(ms) {
const date = new Date(ms);
if (!Number.isFinite(date.getTime())) {
return '—';
}
return date
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/, ' UTC');
}
function getTokenUnit() {
const value = safeGet(KEYS.tokenUnit);
return value === 'm' || value === 'yi' ? value : 'raw';
}
function formatTokens(value) {
const tokens = toNumber(value);
const unit = getTokenUnit();
if (unit === 'm') {
return `${formatNumber(tokens / 1_000_000, 2)}M`;
}
if (unit === 'yi') {
return `${formatNumber(tokens / 100_000_000, 2)}亿`;
}
return formatInteger(tokens);
}
function tokenLabel(label) {
const unit = getTokenUnit();
if (unit === 'm') return `${label} (M)`;
if (unit === 'yi') return `${label} (亿)`;
return label;
}
function tokenHint(value) {
const unit = getTokenUnit();
if (unit === 'm') {
return `≈ ${formatNumber(toNumber(value) / 10_000, 2)} 万 tokens`;
}
if (unit === 'yi') {
return `≈ ${formatNumber(
toNumber(value) / 100_000_000,
2
)} 亿 tokens`;
}
return '';
}
function tokenTotal(object = {}) {
const explicitTotal = Number(object.text_total_tokens);
if (Number.isFinite(explicitTotal)) {
return explicitTotal;
}
return (
toNumber(object.cached_text_input_tokens) +
toNumber(object.uncached_text_input_tokens) +
toNumber(object.text_output_tokens)
);
}
function ymdUtc(ms) {
const date = new Date(ms);
return [
date.getUTCFullYear(),
pad2(date.getUTCMonth() + 1),
pad2(date.getUTCDate()),
].join('-');
}
function ymdLocal(ms) {
const date = new Date(ms);
date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
return date.toISOString().slice(0, 10);
}
function ymdForApi(ms) {
return CONFIG.DATE_BUCKET_MODE === 'utc'
? ymdUtc(ms)
: ymdLocal(ms);
}
function addDays(ms, days) {
if (CONFIG.DATE_BUCKET_MODE === 'utc') {
return ms + days * DAY_MS;
}
const date = new Date(ms);
date.setDate(date.getDate() + days);
return date.getTime();
}
function firstDayOfMonth(ms) {
const date = new Date(ms);
if (CONFIG.DATE_BUCKET_MODE === 'utc') {
return `${date.getUTCFullYear()}-${pad2(
date.getUTCMonth() + 1
)}-01`;
}
return ymdLocal(
new Date(date.getFullYear(), date.getMonth(), 1).getTime()
);
}
function utcOffsetLabel(ms) {
const offsetMinutes = -new Date(ms).getTimezoneOffset();
const sign = offsetMinutes >= 0 ? '+' : '-';
const absolute = Math.abs(offsetMinutes);
return (
`UTC${sign}` +
`${pad2(Math.floor(absolute / 60))}:` +
`${pad2(absolute % 60)}`
);
}
function looksLikeJwt(value) {
return (
typeof value === 'string' &&
value.length > 100 &&
value.split('.').length >= 3
);
}
function findAccessToken(value, depth = 0, visited = new WeakSet()) {
if (
!value ||
typeof value !== 'object' ||
depth > 8 ||
visited.has(value)
) {
return '';
}
visited.add(value);
for (const [key, child] of Object.entries(value)) {
if (
typeof child === 'string' &&
/access/i.test(key) &&
looksLikeJwt(child)
) {
return child;
}
if (child && typeof child === 'object') {
const found = findAccessToken(child, depth + 1, visited);
if (found) return found;
}
}
return '';
}
async function getAccessToken(signal) {
try {
const response = await fetch('/api/auth/session', {
credentials: 'include',
headers: {
accept: 'application/json',
},
signal,
});
if (!response.ok) return '';
return findAccessToken(await response.json());
} catch (error) {
if (error?.name === 'AbortError') throw error;
return '';
}
}
async function apiGet(path, headers, signal) {
const response = await fetch(path, {
method: 'GET',
credentials: 'include',
headers,
signal,
});
if (!response.ok) {
throw new HttpError(response, path);
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new Error(`接口没有返回 JSON:${path}`);
}
return response.json();
}
function parseWindow(label, windowData) {
const usedPercent = requireNumber(
windowData?.used_percent,
`${label}.used_percent`
);
const windowSeconds = requireNumber(
windowData?.limit_window_seconds,
`${label}.limit_window_seconds`
);
const resetAfterSeconds = requireNumber(
windowData?.reset_after_seconds,
`${label}.reset_after_seconds`
);
const resetAtSeconds = requireNumber(
windowData?.reset_at,
`${label}.reset_at`
);
if (
usedPercent < 0 ||
usedPercent > 100 ||
windowSeconds <= 0 ||
resetAfterSeconds < 0 ||
resetAtSeconds <= 0
) {
throw new Error(`${label} 返回了不合理的窗口数据`);
}
const resetAtMs = resetAtSeconds * 1000;
const windowStartMs = resetAtMs - windowSeconds * 1000;
const serverNowMs = resetAtMs - resetAfterSeconds * 1000;
const earliestReasonable = Date.UTC(2020, 0, 1);
const latestReasonable = Date.now() + 2 * DAY_MS;
if (
serverNowMs < earliestReasonable ||
serverNowMs > latestReasonable
) {
throw new Error(`${label} 返回了异常的服务器时间`);
}
return {
name: label,
usedPercent,
usedRatio: usedPercent / 100,
windowSeconds,
windowDays: round(windowSeconds / 86_400, 4),
windowStartMs,
resetAtMs,
serverNowMs,
resetAfterSeconds,
resetAfterHours: round(resetAfterSeconds / 3600, 2),
windowStartUtc: formatUtc(windowStartMs),
windowStartLocal: formatLocal(windowStartMs),
resetAtUtc: formatUtc(resetAtMs),
resetAtLocal: formatLocal(resetAtMs),
serverNowUtc: formatUtc(serverNowMs),
serverNowLocal: formatLocal(serverNowMs),
};
}
function collectWindows(usage) {
const windows = [];
if (usage?.rate_limit?.primary_window) {
windows.push(
parseWindow(
'主限制 - 5小时窗口',
usage.rate_limit.primary_window
)
);
}
if (usage?.rate_limit?.secondary_window) {
windows.push(
parseWindow(
'主限制 - 7天窗口',
usage.rate_limit.secondary_window
)
);
}
for (const item of usage?.additional_rate_limits ?? []) {
const name =
item.limit_name ||
item.metered_feature ||
'额外限制';
if (item?.rate_limit?.primary_window) {
windows.push(
parseWindow(
`${name} - 5小时窗口`,
item.rate_limit.primary_window
)
);
}
if (item?.rate_limit?.secondary_window) {
windows.push(
parseWindow(
`${name} - 7天窗口`,
item.rate_limit.secondary_window
)
);
}
}
return windows;
}
function parseDailyRows(json) {
const data = Array.isArray(json?.data) ? json.data : [];
return data
.slice()
.sort((a, b) =>
String(a?.date ?? '').localeCompare(String(b?.date ?? ''))
)
.map(day => {
const totals = day?.totals ?? {};
const credits = toNumber(totals.credits);
const clients = Array.isArray(day?.clients)
? day.clients
: [];
return {
date: day?.date ?? '',
credits: round(credits, 6),
usd: round(credits * CONFIG.USD_PER_CREDIT, 2),
users: toNumber(totals.users),
threads: toNumber(totals.threads),
turns: toNumber(totals.turns),
tokens: tokenTotal(totals),
cachedInputTokens: toNumber(
totals.cached_text_input_tokens
),
uncachedInputTokens: toNumber(
totals.uncached_text_input_tokens
),
outputTokens: toNumber(totals.text_output_tokens),
clientCount: clients.length,
clientCredits: clients
.map(client => {
const id = client?.client_id ?? 'UNKNOWN';
return `${id}:${round(
toNumber(client?.credits),
2
)}`;
})
.join(' | '),
};
});
}
function summarizeClients(json) {
const map = new Map();
const data = Array.isArray(json?.data) ? json.data : [];
for (const day of data) {
const clients = Array.isArray(day?.clients)
? day.clients
: [];
for (const client of clients) {
const id = client?.client_id ?? 'UNKNOWN';
const row = map.get(id) ?? {
client: id,
credits: 0,
usd: 0,
threads: 0,
turns: 0,
tokens: 0,
cachedInputTokens: 0,
uncachedInputTokens: 0,
outputTokens: 0,
};
const credits = toNumber(client?.credits);
row.credits += credits;
row.usd += credits * CONFIG.USD_PER_CREDIT;
row.threads += toNumber(client?.threads);
row.turns += toNumber(client?.turns);
row.tokens += tokenTotal(client);
row.cachedInputTokens += toNumber(
client?.cached_text_input_tokens
);
row.uncachedInputTokens += toNumber(
client?.uncached_text_input_tokens
);
row.outputTokens += toNumber(
client?.text_output_tokens
);
map.set(id, row);
}
}
return [...map.values()]
.map(row => ({
...row,
credits: round(row.credits, 6),
usd: round(row.usd, 2),
}))
.sort(
(a, b) =>
b.tokens - a.tokens ||
b.credits - a.credits
);
}
async function fetchDailyUsage(
startDate,
endExclusiveDate,
headers,
signal
) {
const query = new URLSearchParams({
start_date: startDate,
end_date: endExclusiveDate,
group_by: 'day',
});
const path = `${CONFIG.DAILY_USAGE_PATH}?${query}`;
const json = await apiGet(path, headers, signal);
return {
url: location.origin + path,
rows: parseDailyRows(json),
clients: summarizeClients(json),
};
}
function summarizeRows(
rangeName,
rows,
startDate,
endExclusiveDate
) {
const credits = rows.reduce(
(sum, row) => sum + toNumber(row.credits),
0
);
return {
range: rangeName,
bucketMode:
CONFIG.DATE_BUCKET_MODE === 'utc'
? 'UTC日期桶'
: '本地日期桶',
startDate,
endExclusiveDate,
dateBuckets: rows.length,
firstDate: rows[0]?.date ?? '',
lastDate: rows.at(-1)?.date ?? '',
credits: round(credits, 6),
usd: round(credits * CONFIG.USD_PER_CREDIT, 2),
tokens: rows.reduce(
(sum, row) => sum + toNumber(row.tokens),
0
),
threads: rows.reduce(
(sum, row) => sum + toNumber(row.threads),
0
),
turns: rows.reduce(
(sum, row) => sum + toNumber(row.turns),
0
),
};
}
function buildWeeklyEstimate({
mainSecondary,
sinceResetRows,
sinceResetSummary,
sinceResetStartDate,
}) {
const usedPercent = toNumber(mainSecondary.usedPercent);
const usedRatio = usedPercent / 100;
if (usedRatio < 0.01 || usedRatio > 1) {
return {
basis: '主限制 - 7天窗口',
usedPercent,
note:
usedRatio <= 0
? '当前已用比例为 0,无法反推额度'
: '当前已用比例低于 1% 或数据异常,估算误差过大',
};
}
const includedCredits = toNumber(
sinceResetSummary.credits
);
const resetDay = sinceResetRows.find(
row => row.date === sinceResetStartDate
);
const resetDayCredits = toNumber(resetDay?.credits);
const excludedCredits = Math.max(
0,
includedCredits - resetDayCredits
);
const totalWithResetDay =
includedCredits / usedRatio;
const totalWithoutResetDay =
excludedCredits / usedRatio;
const remainingWithResetDay = Math.max(
0,
totalWithResetDay - includedCredits
);
const remainingWithoutResetDay = Math.max(
0,
totalWithoutResetDay - excludedCredits
);
return {
basis: '主限制 - 7天窗口',
usedPercent,
usedRatio: round(usedRatio, 4),
remainingRatio: round(1 - usedRatio, 4),
includedCredits: round(includedCredits, 6),
resetDayCredits: round(resetDayCredits, 6),
excludedCredits: round(excludedCredits, 6),
totalCreditsWithResetDay: round(
totalWithResetDay,
2
),
totalCreditsWithoutResetDay: round(
totalWithoutResetDay,
2
),
remainingCreditsWithResetDay: round(
remainingWithResetDay,
2
),
remainingCreditsWithoutResetDay: round(
remainingWithoutResetDay,
2
),
remainingUsdWithoutResetDay: round(
remainingWithoutResetDay *
CONFIG.USD_PER_CREDIT,
2
),
note:
'每日统计只能按天聚合,实际结果通常处于包含和排除重置日两种估算之间。',
};
}
async function fetchCompassDataWithHeaders(
headers,
signal
) {
const usage = await apiGet(
CONFIG.USAGE_PATH,
headers,
signal
);
if (!usage?.rate_limit?.secondary_window) {
throw new Error(
'没有找到 rate_limit.secondary_window,接口结构可能已经变化'
);
}
const windows = collectWindows(usage);
const mainSecondary = parseWindow(
'主限制 - 7天窗口',
usage.rate_limit.secondary_window
);
const apiNowMs = mainSecondary.serverNowMs;
const apiTodayDate = ymdForApi(apiNowMs);
const endExclusiveDate = ymdForApi(
addDays(apiNowMs, 1)
);
const sinceResetStartDate = ymdForApi(
mainSecondary.windowStartMs
);
const monthStartDate = firstDayOfMonth(apiNowMs);
const rollingStartDate = ymdForApi(
addDays(apiNowMs, -(CONFIG.ROLLING_DAYS - 1))
);
const [
sinceReset,
monthToDate,
rolling,
] = await Promise.all([
fetchDailyUsage(
sinceResetStartDate,
endExclusiveDate,
headers,
signal
),
fetchDailyUsage(
monthStartDate,
endExclusiveDate,
headers,
signal
),
fetchDailyUsage(
rollingStartDate,
endExclusiveDate,
headers,
signal
),
]);
const sinceResetSummary = summarizeRows(
`上次重置至今 ${sinceResetStartDate} ~ ${apiTodayDate}`,
sinceReset.rows,
sinceResetStartDate,
endExclusiveDate
);
const monthToDateSummary = summarizeRows(
`本月初至今 ${monthStartDate} ~ ${apiTodayDate}`,
monthToDate.rows,
monthStartDate,
endExclusiveDate
);
const rollingSummary = summarizeRows(
`近${CONFIG.ROLLING_DAYS}天 ${rollingStartDate} ~ ${apiTodayDate}`,
rolling.rows,
rollingStartDate,
endExclusiveDate
);
const weeklyEstimate = buildWeeklyEstimate({
mainSecondary,
sinceResetRows: sinceReset.rows,
sinceResetSummary,
sinceResetStartDate,
});
return {
fetchedAt: Date.now(),
browserTimeZone:
Intl.DateTimeFormat().resolvedOptions()
.timeZone || '未知',
browserUtcOffset: utcOffsetLabel(apiNowMs),
apiTodayDate,
windows,
mainSecondary,
weeklyEstimate,
ranges: {
sinceReset: {
label: '上次重置至今',
summary: sinceResetSummary,
rows: sinceReset.rows,
clients: sinceReset.clients,
},
monthToDate: {
label: '本月初至今',
summary: monthToDateSummary,
rows: monthToDate.rows,
clients: monthToDate.clients,
},
rolling: {
label: `近${CONFIG.ROLLING_DAYS}天`,
summary: rollingSummary,
rows: rolling.rows,
clients: rolling.clients,
},
},
};
}
async function loadCompassData(signal) {
const cookieHeaders = {
accept: 'application/json',
};
try {
return await fetchCompassDataWithHeaders(
cookieHeaders,
signal
);
} catch (error) {
if (error?.name === 'AbortError') throw error;
if (!(error instanceof HttpError) || error.status !== 401) {
throw error;
}
}
const accessToken = await getAccessToken(signal);
if (!accessToken) {
throw new Error(
'没有获得有效授权,请确认当前浏览器已经登录 chatgpt.com'
);
}
return fetchCompassDataWithHeaders(
{
accept: 'application/json',
authorization: `Bearer ${accessToken}`,
},
signal
);
}
function parseRgb(color) {
const match = String(color || '').match(
/rgba?\(([^)]+)\)/i
);
if (!match) return null;
const values = match[1]
.split(',')
.map(value => Number.parseFloat(value.trim()));
const [r, g, b, alpha = 1] = values;
if (
![r, g, b, alpha].every(Number.isFinite) ||
alpha <= 0.01
) {
return null;
}
return { r, g, b };
}
function luminance({ r, g, b }) {
const channel = value => {
const normalized = value / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
return (
0.2126 * channel(r) +
0.7152 * channel(g) +
0.0722 * channel(b)
);
}
function detectTheme() {
const bodyBackground =
getComputedStyle(document.body).backgroundColor;
const rootBackground =
getComputedStyle(
document.documentElement
).backgroundColor;
const rgb =
parseRgb(bodyBackground) ||
parseRgb(rootBackground);
if (rgb) {
return luminance(rgb) < 0.45
? 'dark'
: 'light';
}
return matchMedia(
'(prefers-color-scheme: dark)'
).matches
? 'dark'
: 'light';
}
function syncTheme(root) {
root.dataset.theme = detectTheme();
}
function addStyles() {
const css = `
#${ROOT_ID} {
--bg: #fff;
--panel: #f7f7f8;
--panel2: #ececec;
--text: #111;
--muted: #666;
--line: rgba(0,0,0,.12);
--shadow: rgba(0,0,0,.2);
--accent: #10a37f;
--warning: #d97706;
--danger: #dc2626;
position: fixed;
top: 72px;
right: 18px;
z-index: 2147483000;
width: min(470px, calc(100vw - 30px));
max-height: calc(100vh - 90px);
color: var(--text);
font: 13px/1.45 system-ui, sans-serif;
}
#${ROOT_ID}[data-theme="dark"] {
--bg: #212121;
--panel: #2f2f2f;
--panel2: #383838;
--text: #ececec;
--muted: #b4b4b4;
--line: rgba(255,255,255,.14);
--shadow: rgba(0,0,0,.45);
--warning: #f59e0b;
--danger: #f87171;
}
#${ROOT_ID} * {
box-sizing: border-box;
}
#${ROOT_ID}.collapsed {
width: auto;
}
#${ROOT_ID}.collapsed .body {
display: none;
}
#${ROOT_ID}.collapsed .shell {
resize: none;
min-width: 0;
min-height: 0;
}
#${ROOT_ID}.fullscreen {
inset: 14px !important;
width: auto !important;
max-height: none;
}
#${ROOT_ID}.fullscreen .shell {
width: 100%;
height: calc(100vh - 28px);
resize: none;
}
#${ROOT_ID}.fullscreen .body {
max-height: calc(100vh - 82px);
}
#${ROOT_ID} .shell {
width: 100%;
min-width: 350px;
min-height: 220px;
max-width: calc(100vw - 30px);
max-height: calc(100vh - 90px);
overflow: hidden;
resize: both;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg);
box-shadow: 0 18px 50px var(--shadow);
}
#${ROOT_ID} .header {
display: flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 9px 11px;
border-bottom: 1px solid var(--line);
cursor: move;
user-select: none;
}
#${ROOT_ID}.fullscreen .header {
cursor: default;
}
#${ROOT_ID} .title {
flex: 1;
min-width: 0;
}
#${ROOT_ID} .title strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${ROOT_ID} .status,
#${ROOT_ID} .sub,
#${ROOT_ID} .label {
color: var(--muted);
font-size: 11px;
}
#${ROOT_ID} .actions {
display: flex;
gap: 5px;
}
#${ROOT_ID} button {
min-height: 29px;
border: 1px solid var(--line);
border-radius: 7px;
padding: 4px 8px;
background: var(--panel2);
color: var(--text);
cursor: pointer;
font: inherit;
}
#${ROOT_ID} .icon {
width: 30px;
padding: 0;
}
#${ROOT_ID} .body {
max-height: calc(100vh - 140px);
overflow: auto;
padding: 11px;
}
#${ROOT_ID} .grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
#${ROOT_ID} .card {
min-width: 0;
border: 1px solid var(--line);
border-radius: 9px;
padding: 10px;
background: var(--panel);
}
#${ROOT_ID} .wide {
grid-column: 1 / -1;
}
#${ROOT_ID} .value {
margin-top: 2px;
font-size: 19px;
font-weight: 700;
overflow-wrap: anywhere;
}
#${ROOT_ID} .meter {
display: grid;
grid-template-columns: 84px 1fr;
align-items: center;
gap: 12px;
}
#${ROOT_ID} .ring {
display: grid;
place-items: center;
width: 84px;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(
circle,
var(--panel) 0 56%,
transparent 57%
),
conic-gradient(
var(--ring-color) calc(var(--percent) * 1%),
var(--panel2) 0
);
}
#${ROOT_ID} .ring-inner {
text-align: center;
font-weight: 800;
}
#${ROOT_ID} .kv {
display: flex;
justify-content: space-between;
gap: 10px;
margin-top: 6px;
padding-bottom: 5px;
border-bottom: 1px solid var(--line);
}
#${ROOT_ID} .kv span:first-child {
color: var(--muted);
}
#${ROOT_ID} .kv span:last-child {
text-align: right;
overflow-wrap: anywhere;
}
#${ROOT_ID} .tabs {
display: flex;
gap: 6px;
margin: 11px 0 8px;
}
#${ROOT_ID} .tab {
flex: 1;
color: var(--muted);
}
#${ROOT_ID} .tab.active {
border-color: var(--accent);
color: var(--text);
}
#${ROOT_ID} .section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 12px 0 7px;
}
#${ROOT_ID} .table-wrap {
max-height: 280px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
#${ROOT_ID} table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
background: var(--panel);
font-size: 12px;
}
#${ROOT_ID} th,
#${ROOT_ID} td {
padding: 7px 8px;
border-bottom: 1px solid var(--line);
text-align: right;
white-space: nowrap;
}
#${ROOT_ID} th:first-child,
#${ROOT_ID} td:first-child {
text-align: left;
}
#${ROOT_ID} th {
position: sticky;
top: 0;
z-index: 1;
background: var(--panel2);
}
#${ROOT_ID} .loading {
display: flex;
align-items: center;
gap: 9px;
color: var(--muted);
}
#${ROOT_ID} .spinner {
width: 17px;
height: 17px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: cq-spin .8s linear infinite;
}
#${ROOT_ID} .error {
padding: 10px;
border: 1px solid var(--danger);
border-radius: 8px;
white-space: pre-wrap;
}
@keyframes cq-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
#${ROOT_ID} {
top: 62px;
left: 10px;
right: 10px;
width: auto;
}
#${ROOT_ID} .grid {
grid-template-columns: 1fr;
}
#${ROOT_ID} .wide {
grid-column: auto;
}
#${ROOT_ID} .shell {
min-width: 0;
}
}
`;
if (typeof GM_addStyle === 'function') {
GM_addStyle(css);
} else {
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
}
function readPosition() {
try {
const position = JSON.parse(
safeGet(KEYS.position) || 'null'
);
if (
!position ||
!Number.isFinite(position.left) ||
!Number.isFinite(position.top)
) {
return null;
}
return position;
} catch {
return null;
}
}
function applyPosition(root) {
const position = readPosition();
if (!position) return;
root.style.left = `${clamp(
position.left,
5,
window.innerWidth - 80
)}px`;
root.style.top = `${clamp(
position.top,
5,
window.innerHeight - 44
)}px`;
root.style.right = 'auto';
root.style.bottom = 'auto';
}
function savePosition(root) {
const rectangle = root.getBoundingClientRect();
safeSet(
KEYS.position,
JSON.stringify({
left: Math.round(rectangle.left),
top: Math.round(rectangle.top),
})
);
}
function updateButtons(root) {
const unitButton = root.querySelector(
'[data-action="unit"]'
);
const fullscreenButton = root.querySelector(
'[data-action="fullscreen"]'
);
const collapseButton = root.querySelector(
'[data-action="collapse"]'
);
const unit = getTokenUnit();
unitButton.textContent =
unit === 'm'
? 'Token: M'
: unit === 'yi'
? 'Token: 亿'
: 'Token: 原始';
const fullscreen =
root.classList.contains('fullscreen');
fullscreenButton.textContent =
fullscreen ? '↙' : '⛶';
fullscreenButton.title =
fullscreen ? '还原' : '全屏';
const collapsed =
root.classList.contains('collapsed');
collapseButton.textContent =
collapsed ? '▸' : '▾';
collapseButton.title =
collapsed ? '展开' : '折叠';
}
function setStatus(root, text) {
const element = root.querySelector(
'[data-status]'
);
if (element) element.textContent = text;
}
function setupDrag(root) {
const header = root.querySelector('.header');
let dragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
function onMove(event) {
if (!dragging) return;
const rectangle = root.getBoundingClientRect();
const nextLeft = clamp(
startLeft + event.clientX - startX,
4,
window.innerWidth -
Math.min(80, rectangle.width)
);
const nextTop = clamp(
startTop + event.clientY - startY,
4,
window.innerHeight -
Math.min(44, rectangle.height)
);
root.style.left = `${nextLeft}px`;
root.style.top = `${nextTop}px`;
root.style.right = 'auto';
root.style.bottom = 'auto';
}
function onUp() {
if (!dragging) return;
dragging = false;
document.removeEventListener(
'mousemove',
onMove
);
document.removeEventListener(
'mouseup',
onUp
);
savePosition(root);
}
header.addEventListener('mousedown', event => {
if (
event.button !== 0 ||
root.classList.contains('fullscreen') ||
event.target.closest('button, a, input, select')
) {
return;
}
const rectangle =
root.getBoundingClientRect();
dragging = true;
startX = event.clientX;
startY = event.clientY;
startLeft = rectangle.left;
startTop = rectangle.top;
root.style.left = `${rectangle.left}px`;
root.style.top = `${rectangle.top}px`;
root.style.right = 'auto';
root.style.bottom = 'auto';
document.addEventListener(
'mousemove',
onMove
);
document.addEventListener(
'mouseup',
onUp
);
event.preventDefault();
});
window.addEventListener('resize', () => {
if (!root.classList.contains('fullscreen')) {
applyPosition(root);
savePosition(root);
}
});
}
function createRoot() {
let root = document.getElementById(ROOT_ID);
if (root) return root;
root = document.createElement('div');
root.id = ROOT_ID;
if (safeGet(KEYS.collapsed) === '1') {
root.classList.add('collapsed');
}
if (safeGet(KEYS.fullscreen) === '1') {
root.classList.add('fullscreen');
}
root.innerHTML = `
<div class="shell">
<div class="header">
<div class="title">
<strong>
Codex Quota Compass v${VERSION}
</strong>
<span class="status" data-status>
准备加载
</span>
</div>
<div class="actions">
<button type="button" data-action="unit">
Token: 原始
</button>
<button
class="icon"
type="button"
data-action="refresh"
title="刷新"
>↻</button>
<button
class="icon"
type="button"
data-action="fullscreen"
title="全屏"
>⛶</button>
<button
class="icon"
type="button"
data-action="collapse"
title="折叠"
>▾</button>
</div>
</div>
<div class="body" data-body></div>
</div>
`;
document.documentElement.appendChild(root);
syncTheme(root);
if (!root.classList.contains('fullscreen')) {
applyPosition(root);
}
const observer = new MutationObserver(() =>
syncTheme(root)
);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: [
'class',
'style',
'data-theme',
],
});
const media = matchMedia(
'(prefers-color-scheme: dark)'
);
const themeHandler = () => syncTheme(root);
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', themeHandler);
} else if (typeof media.addListener === 'function') {
media.addListener(themeHandler);
}
root
.querySelector('[data-action="unit"]')
.addEventListener('click', () => {
const current = getTokenUnit();
const next =
current === 'raw'
? 'm'
: current === 'm'
? 'yi'
: 'raw';
safeSet(KEYS.tokenUnit, next);
updateButtons(root);
if (root.__data) {
render(root, root.__data);
}
});
root
.querySelector('[data-action="refresh"]')
.addEventListener('click', () =>
refresh(root)
);
root
.querySelector('[data-action="fullscreen"]')
.addEventListener('click', () => {
root.classList.toggle('fullscreen');
if (root.classList.contains('fullscreen')) {
root.classList.remove('collapsed');
safeSet(KEYS.collapsed, '0');
} else {
applyPosition(root);
}
safeSet(
KEYS.fullscreen,
root.classList.contains('fullscreen')
? '1'
: '0'
);
updateButtons(root);
});
root
.querySelector('[data-action="collapse"]')
.addEventListener('click', () => {
root.classList.toggle('collapsed');
if (root.classList.contains('collapsed')) {
root.classList.remove('fullscreen');
safeSet(KEYS.fullscreen, '0');
}
safeSet(
KEYS.collapsed,
root.classList.contains('collapsed')
? '1'
: '0'
);
updateButtons(root);
});
setupDrag(root);
updateButtons(root);
return root;
}
function usageColor(percent) {
if (percent >= 85) return 'var(--danger)';
if (percent >= 65) return 'var(--warning)';
return 'var(--accent)';
}
function renderWindow(windowData) {
const percent = clamp(
toNumber(windowData.usedPercent),
0,
100
);
return `
<div class="card wide">
<div class="meter">
<div
class="ring"
style="
--percent:${percent};
--ring-color:${usageColor(percent)};
"
>
<div class="ring-inner">
<div class="label">已用</div>
<div>${formatPercent(percent)}</div>
</div>
</div>
<div>
<div class="label">
${escapeHtml(windowData.name)}
</div>
<div class="value">
${formatNumber(100 - percent, 2)}% 剩余
</div>
<div class="kv">
<span>下次重置</span>
<span>
${escapeHtml(
windowData.resetAtLocal
)}
</span>
</div>
<div class="kv">
<span>距离重置</span>
<span>
${formatNumber(
windowData.resetAfterHours,
2
)} 小时
</span>
</div>
</div>
</div>
</div>
`;
}
function renderSummary(data) {
const main5Hour = data.windows.find(
item => item.name === '主限制 - 5小时窗口'
);
const estimate = data.weeklyEstimate;
const rolling = data.ranges.rolling.summary;
const hint = tokenHint(rolling.tokens);
return `
<div class="grid">
${renderWindow(data.mainSecondary)}
${main5Hour ? renderWindow(main5Hour) : ''}
<div class="card">
<div class="label">
近${CONFIG.ROLLING_DAYS}天 Token
</div>
<div class="value">
${formatTokens(rolling.tokens)}
</div>
<div class="sub">
${hint ? `${escapeHtml(hint)} · ` : ''}
${formatInteger(rolling.turns)} 轮 ·
${formatInteger(rolling.threads)} 线程
</div>
</div>
<div class="card">
<div class="label">反推周额度</div>
<div class="value">
${formatOptional(
estimate.totalCreditsWithoutResetDay,
2
)}
</div>
<div class="sub">
排除重置日口径 Credits
</div>
</div>
<div class="card">
<div class="label">估算剩余额度</div>
<div class="value">
${formatOptional(
estimate.remainingCreditsWithoutResetDay,
2
)}
</div>
<div class="sub">
约 $${formatOptional(
estimate.remainingUsdWithoutResetDay,
2
)}
</div>
</div>
<div class="card">
<div class="label">日期桶</div>
<div class="value">
${escapeHtml(data.apiTodayDate)}
</div>
<div class="sub">
${escapeHtml(data.browserTimeZone)} ·
${escapeHtml(data.browserUtcOffset)}
</div>
</div>
${
estimate.note
? `
<div class="card wide">
<div class="label">估算说明</div>
<div class="sub">
${escapeHtml(estimate.note)}
</div>
</div>
`
: ''
}
</div>
`;
}
function renderTable(headers, rows) {
if (!rows.length) {
return `
<div class="card">
暂无数据
</div>
`;
}
return `
<div class="table-wrap">
<table>
<thead>
<tr>
${headers
.map(
header =>
`<th>${escapeHtml(
header.label
)}</th>`
)
.join('')}
</tr>
</thead>
<tbody>
${rows
.map(
row => `
<tr>
${headers
.map(header => {
const raw = row[header.key];
const formatted =
typeof header.format ===
'function'
? header.format(raw, row)
: raw;
return `<td>${escapeHtml(
formatted
)}</td>`;
})
.join('')}
</tr>
`
)
.join('')}
</tbody>
</table>
</div>
`;
}
function rowsToCsv(rows) {
if (!rows.length) return '';
const keys = Object.keys(rows[0]);
const cell = value =>
`"${String(value ?? '').replace(
/"/g,
'""'
)}"`;
return [
keys.map(cell).join(','),
...rows.map(row =>
keys
.map(key => cell(row[key]))
.join(',')
),
].join('\n');
}
async function copyCsv(rows, button) {
const oldText = button.textContent;
try {
await navigator.clipboard.writeText(
rowsToCsv(rows)
);
button.textContent = '已复制';
} catch {
button.textContent = '复制失败';
} finally {
setTimeout(() => {
button.textContent = oldText;
}, 1200);
}
}
function renderRange(root, data, key) {
const range = data.ranges[key];
if (!range) return;
root.dataset.range = key;
root.querySelectorAll('.tab').forEach(button => {
button.classList.toggle(
'active',
button.dataset.range === key
);
});
const summaryElement = root.querySelector(
'[data-range-summary]'
);
const rowsElement = root.querySelector(
'[data-range-rows]'
);
const clientsElement = root.querySelector(
'[data-range-clients]'
);
const hint = tokenHint(range.summary.tokens);
summaryElement.innerHTML = `
<div class="grid">
<div class="card">
<div class="label">范围</div>
<div class="value" style="font-size:15px">
${escapeHtml(range.summary.range)}
</div>
<div class="sub">
${formatInteger(
range.summary.dateBuckets
)} 个日期桶
</div>
</div>
<div class="card">
<div class="label">累计 Token</div>
<div class="value">
${formatTokens(range.summary.tokens)}
</div>
<div class="sub">
${hint ? `${escapeHtml(hint)} · ` : ''}
${formatInteger(
range.summary.turns
)} 轮 ·
${formatInteger(
range.summary.threads
)} 线程
</div>
</div>
<div class="card">
<div class="label">累计 Credits</div>
<div class="value">
${formatNumber(
range.summary.credits,
6
)}
</div>
<div class="sub">
约 $${formatNumber(
range.summary.usd,
2
)}
</div>
</div>
<div class="card">
<div class="label">API 日期范围</div>
<div class="value" style="font-size:14px">
${escapeHtml(
range.summary.startDate
)}
→
${escapeHtml(
range.summary.endExclusiveDate
)}
</div>
<div class="sub">
end_date 为排他边界
</div>
</div>
</div>
`;
rowsElement.innerHTML = renderTable(
[
{ key: 'date', label: '日期桶' },
{
key: 'tokens',
label: tokenLabel('Token'),
format: formatTokens,
},
{
key: 'cachedInputTokens',
label: tokenLabel('缓存输入'),
format: formatTokens,
},
{
key: 'uncachedInputTokens',
label: tokenLabel('非缓存输入'),
format: formatTokens,
},
{
key: 'outputTokens',
label: tokenLabel('输出'),
format: formatTokens,
},
{
key: 'turns',
label: '轮数',
format: formatInteger,
},
{
key: 'threads',
label: '线程',
format: formatInteger,
},
{
key: 'credits',
label: 'Credits',
format: value =>
formatNumber(value, 6),
},
{
key: 'clientCredits',
label: '客户端 Credits',
},
],
range.rows
);
clientsElement.innerHTML = renderTable(
[
{ key: 'client', label: '客户端' },
{
key: 'tokens',
label: tokenLabel('Token'),
format: formatTokens,
},
{
key: 'cachedInputTokens',
label: tokenLabel('缓存输入'),
format: formatTokens,
},
{
key: 'uncachedInputTokens',
label: tokenLabel('非缓存输入'),
format: formatTokens,
},
{
key: 'outputTokens',
label: tokenLabel('输出'),
format: formatTokens,
},
{
key: 'turns',
label: '轮数',
format: formatInteger,
},
{
key: 'threads',
label: '线程',
format: formatInteger,
},
{
key: 'credits',
label: 'Credits',
format: value =>
formatNumber(value, 6),
},
],
range.clients
);
root.querySelector(
'[data-copy="days"]'
).onclick = event =>
copyCsv(
range.rows,
event.currentTarget
);
root.querySelector(
'[data-copy="clients"]'
).onclick = event =>
copyCsv(
range.clients,
event.currentTarget
);
}
function render(root, data) {
root.__data = data;
updateButtons(root);
const activeRange =
data.ranges[root.dataset.range]
? root.dataset.range
: 'sinceReset';
const body = root.querySelector('[data-body]');
body.innerHTML = `
${renderSummary(data)}
<div class="section">
<strong>用量区间</strong>
</div>
<div class="tabs">
${Object.entries(data.ranges)
.map(
([key, range]) => `
<button
class="tab"
type="button"
data-range="${key}"
>
${escapeHtml(range.label)}
</button>
`
)
.join('')}
</div>
<div data-range-summary></div>
<div class="section">
<strong>每日明细</strong>
<button
type="button"
data-copy="days"
>
复制 CSV
</button>
</div>
<div data-range-rows></div>
<div class="section">
<strong>客户端汇总</strong>
<button
type="button"
data-copy="clients"
>
复制 CSV
</button>
</div>
<div data-range-clients></div>
<div class="sub" style="margin-top:10px">
数据来自当前 ChatGPT 登录会话。
脚本不会保存或显示访问令牌和 Cookie。
</div>
`;
root.querySelectorAll('.tab').forEach(button => {
button.addEventListener('click', () => {
renderRange(
root,
data,
button.dataset.range
);
});
});
renderRange(root, data, activeRange);
setStatus(
root,
`已更新:${new Date(
data.fetchedAt
).toLocaleTimeString()}`
);
}
function friendlyError(error) {
if (!(error instanceof HttpError)) {
return error?.message || String(error);
}
if (error.status === 401) {
return (
'HTTP 401:没有获得有效授权。\n' +
'请确认当前页面已经登录 ChatGPT,然后刷新重试。'
);
}
if (error.status === 403) {
return (
'HTTP 403:当前账号无权访问该接口,' +
'或者接口策略已经变化。'
);
}
if (error.status === 404) {
return (
'HTTP 404:接口不存在,ChatGPT 内部接口可能已经调整。'
);
}
if (error.status === 429) {
return (
'HTTP 429:请求过于频繁,请稍后手动刷新。'
);
}
return (
`HTTP ${error.status}:请求失败。\n` +
`${error.path}`
);
}
async function refresh(root) {
activeController?.abort();
const controller = new AbortController();
activeController = controller;
const currentRequest = ++requestSerial;
const body = root.querySelector('[data-body]');
setStatus(root, '加载中');
body.innerHTML = `
<div class="loading">
<span class="spinner"></span>
<span>正在读取 Codex analytics...</span>
</div>
`;
try {
const data = await loadCompassData(
controller.signal
);
if (
controller.signal.aborted ||
currentRequest !== requestSerial
) {
return;
}
render(root, data);
} catch (error) {
if (
error?.name === 'AbortError' ||
currentRequest !== requestSerial
) {
return;
}
setStatus(root, '加载失败');
body.innerHTML = `
<div class="error">
${escapeHtml(friendlyError(error))}
</div>
`;
}
}
function init() {
if (document.getElementById(ROOT_ID)) return;
addStyles();
const root = createRoot();
refresh(root);
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
init,
{ once: true }
);
} else {
init();
}
})();
- 版本改为
-
worsteggs 07-15 11:089楼好用啊!找了很久。因为我现在是公用官号的,不知道有没有办法统计所有人每个任务的token消耗情况?
-
木 楼主 07-15 11:1110楼官方暂时只能区分客户端 TUI、vs、codex ^-^ 我们也是共用,还是只能靠cc-switch 互相看各自用量
* 帖子来源Linux.do
附近帖子
- ↑【AI+应用多个岗位】杭州【20K-50K】【12-17薪,看岗位】
- ↑跑了一周顺风车,好像有点上瘾了
- ↑[求助] 10月预产期,新手奶爸前来请教:新手奶粉怎么选?求大佬们抄作业
- ↑newAPI游乐场生图报错
- ↑Free Grok账号导入build报403
- 📍 分享一个官号 Codex 用量可视化油猴脚本
- ↓Leetcode每日一题 —— 3658. 奇数和与偶数和的最大公约数
- ↓【翰林文苑】公益站开不开LDC换额度
- ↓gpt5.6 sol token用的快,一定要卸载 superpowers, 太夸张了
- ↓今天有人抢到glm coding plan吗!!!!!
- ↓为啥没人聊Claude的K12一年优惠?