Load Codex analytics in a hidden iframe and show compact draggable 5h/weekly usage on any chatgpt.com page.
// ==UserScript==
// @name ChatGPT Codex Usage Background Sync Widget
// @namespace https://chatgpt.com/
// @version 0.6.1
// @description Load Codex analytics in a hidden iframe and show compact draggable 5h/weekly usage on any chatgpt.com page.
// @match https://chatgpt.com/*
// @run-at document-idle
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(() => {
'use strict';
const STORAGE_KEY = 'codex_usage_sync_v2';
const UI_STATE_KEY = 'codex_usage_widget_ui_v1';
const ANALYTICS_URL = 'https://chatgpt.com/codex/cloud/settings/analytics';
const TARGET_SELECTOR = 'main#main div.flex.flex-1.overflow-hidden div.w-full.overflow-y-auto';
const FETCH_INTERVAL_MS = 5 * 60 * 1000;
const IFRAME_LOAD_TIMEOUT_MS = 12 * 1000;
const IFRAME_WAIT_MS = 15 * 1000;
const START_DELAY_MS = 2500;
const TITLE_PREFIX_ENABLED = false;
let widget = null;
let inFlight = false;
GM_addStyle(`
#codex-usage-widget {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 2147483647;
min-width: 170px;
max-width: 320px;
background: rgba(20,20,20,.92);
color: #fff;
border: 1px solid rgba(255,255,255,.12);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.28);
font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
backdrop-filter: blur(8px);
user-select: none;
overflow: hidden;
}
#codex-usage-widget .cu-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
cursor: move;
background: rgba(255,255,255,.04);
border-bottom: 1px solid rgba(255,255,255,.08);
}
#codex-usage-widget .cu-title {
font-weight: 700;
font-size: 12px;
opacity: .9;
display: flex;
align-items: center;
}
#codex-usage-widget .cu-header-buttons {
display: flex;
align-items: center;
gap: 6px;
}
#codex-usage-widget .cu-btn {
border: 1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.08);
color: #fff;
border-radius: 8px;
padding: 3px 8px;
cursor: pointer;
font-size: 12px;
line-height: 1.2;
}
#codex-usage-widget .cu-btn:hover {
background: rgba(255,255,255,.14);
}
#codex-usage-widget .cu-body {
padding: 8px 10px 10px;
}
#codex-usage-widget .cu-mini {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
white-space: nowrap;
}
#codex-usage-widget .cu-mini-item {
display: inline-flex;
align-items: center;
}
#codex-usage-widget .cu-mini-item.low {
opacity: 1;
}
#codex-usage-widget .cu-mini-item.verylow {
opacity: 1;
}
#codex-usage-widget .cu-mini-item-label {
opacity: .72;
font-weight: 600;
margin-right: 4px;
}
#codex-usage-widget .cu-details {
display: none;
margin-top: 8px;
user-select: text;
}
#codex-usage-widget.expanded .cu-details {
display: block;
}
#codex-usage-widget .row {
margin: 4px 0;
white-space: pre-wrap;
word-break: break-word;
}
#codex-usage-widget .muted {
opacity: .75;
font-size: 12px;
}
#codex-usage-widget .warn {
font-weight: 700;
}
#codex-usage-widget .buttons {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
#codex-usage-widget .status-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 999px;
margin-right: 6px;
background: rgba(255,255,255,.55);
vertical-align: middle;
flex: 0 0 auto;
}
#codex-usage-widget .status-dot.busy {
background: #f59e0b;
}
#codex-usage-widget .status-dot.ok {
background: #10b981;
}
#codex-usage-widget .status-dot.fail {
background: #ef4444;
}
#codex-analytics-hidden-frame {
position: fixed !important;
width: 1px !important;
height: 1px !important;
left: -99999px !important;
top: -99999px !important;
opacity: 0 !important;
pointer-events: none !important;
border: 0 !important;
}
`);
function nowText() {
return new Date().toLocaleString();
}
function normalizeLines(text) {
return String(text || '')
.split('\n')
.map(s => s.trim())
.filter(Boolean);
}
function parsePercent(text) {
const m = String(text || '').match(/(\d+)\s*%/);
return m ? Number(m[1]) : null;
}
function parseUsageBlock(lines, titleText) {
const idx = lines.findIndex(line => line.includes(titleText));
if (idx < 0) return null;
const windowLines = lines.slice(idx + 1, idx + 6);
let percentLine = '';
let remainingLine = '';
let resetLine = '';
for (const line of windowLines) {
if (!percentLine && /\d+\s*%/.test(line)) {
percentLine = line;
continue;
}
if (!remainingLine && line.includes('剩余')) {
remainingLine = line;
continue;
}
if (!resetLine && line.includes('重置时间')) {
resetLine = line;
continue;
}
}
let remainingText = '';
if (percentLine && remainingLine) {
remainingText = `${percentLine} ${remainingLine}`.trim();
} else if (percentLine) {
remainingText = percentLine.trim();
} else if (remainingLine) {
remainingText = remainingLine.trim();
}
return {
title: titleText,
remaining: remainingText,
reset: resetLine || '',
};
}
function extractSections(lines) {
return {
fiveHour: parseUsageBlock(lines, '5 小时使用限额'),
weekly: parseUsageBlock(lines, '每周使用限额'),
};
}
function buildPayloadFromText(text) {
const lines = normalizeLines(text);
const sections = extractSections(lines);
return {
source: 'hidden_iframe_analytics',
savedAt: Date.now(),
savedAtText: nowText(),
fiveHour: sections.fiveHour,
weekly: sections.weekly,
fiveHourPercent: parsePercent(sections.fiveHour?.remaining),
weeklyPercent: parsePercent(sections.weekly?.remaining),
rawLines: lines.slice(0, 50),
};
}
function savePayload(payload) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
function loadPayload() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
} catch {
return null;
}
}
function loadUiState() {
try {
return JSON.parse(localStorage.getItem(UI_STATE_KEY) || 'null') || {};
} catch {
return {};
}
}
function saveUiState(patch) {
const next = { ...loadUiState(), ...patch };
localStorage.setItem(UI_STATE_KEY, JSON.stringify(next));
}
function percentTag(value) {
if (value == null) return '';
if (value <= 10) return 'verylow';
if (value <= 20) return 'low';
return '';
}
function compactPercent(value) {
return value == null ? '--' : `${value}%`;
}
function ensureWidget() {
if (widget && document.body.contains(widget)) return widget;
widget = document.createElement('div');
widget.id = 'codex-usage-widget';
widget.innerHTML = `
<div class="cu-header" id="cu-drag-handle">
<div class="cu-title">
<span class="status-dot" id="cu-status-dot"></span>Codex
</div>
<div class="cu-header-buttons">
<button class="cu-btn" id="cu-expand" type="button">Expand</button>
<button class="cu-btn" id="cu-hide" type="button">Hide</button>
</div>
</div>
<div class="cu-body">
<div class="cu-mini" id="cu-mini">
<div class="cu-mini-item" id="cu-mini-5h"><span class="cu-mini-item-label">5h</span>--</div>
<div class="cu-mini-item" id="cu-mini-week"><span class="cu-mini-item-label">W</span>--</div>
</div>
<div class="cu-details" id="cu-details-wrap">
<div class="row" id="cu-5h">5h: waiting...</div>
<div class="row" id="cu-week">Week: waiting...</div>
<div class="row muted" id="cu-time">Last update: -</div>
<div class="row muted" id="cu-details">-</div>
<div class="buttons">
<button class="cu-btn" id="cu-refresh" type="button">Refresh</button>
<button class="cu-btn" id="cu-open" type="button">Analytics</button>
</div>
</div>
</div>
`;
document.body.appendChild(widget);
const ui = loadUiState();
if (ui.expanded) widget.classList.add('expanded');
applySavedPosition();
widget.querySelector('#cu-expand')?.addEventListener('click', toggleExpand);
widget.querySelector('#cu-hide')?.addEventListener('click', () => {
widget.style.display = 'none';
});
widget.querySelector('#cu-refresh')?.addEventListener('click', () => {
fetchUsageThroughIframe(true);
});
widget.querySelector('#cu-open')?.addEventListener('click', () => {
window.open(ANALYTICS_URL, '_blank', 'noopener');
});
installDrag(widget.querySelector('#cu-drag-handle'));
syncExpandButtonText();
return widget;
}
function applySavedPosition() {
if (!widget) return;
const ui = loadUiState();
if (typeof ui.left === 'number' && typeof ui.top === 'number') {
widget.style.left = `${ui.left}px`;
widget.style.top = `${ui.top}px`;
widget.style.right = 'auto';
widget.style.bottom = 'auto';
}
}
function clampPosition(left, top) {
const w = widget?.offsetWidth || 220;
const h = widget?.offsetHeight || 80;
const maxLeft = Math.max(0, window.innerWidth - w - 4);
const maxTop = Math.max(0, window.innerHeight - h - 4);
return {
left: Math.min(Math.max(0, left), maxLeft),
top: Math.min(Math.max(0, top), maxTop),
};
}
function installDrag(handle) {
if (!handle) return;
let dragging = false;
let startX = 0;
let startY = 0;
let originLeft = 0;
let originTop = 0;
handle.addEventListener('mousedown', (ev) => {
if (ev.target.closest('button')) return;
if (!widget) return;
const rect = widget.getBoundingClientRect();
dragging = true;
startX = ev.clientX;
startY = ev.clientY;
originLeft = rect.left;
originTop = rect.top;
widget.style.left = `${rect.left}px`;
widget.style.top = `${rect.top}px`;
widget.style.right = 'auto';
widget.style.bottom = 'auto';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
ev.preventDefault();
});
function onMove(ev) {
if (!dragging || !widget) return;
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
const pos = clampPosition(originLeft + dx, originTop + dy);
widget.style.left = `${pos.left}px`;
widget.style.top = `${pos.top}px`;
}
function onUp() {
if (!dragging || !widget) return;
dragging = false;
const rect = widget.getBoundingClientRect();
saveUiState({ left: rect.left, top: rect.top });
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
}
function toggleExpand() {
ensureWidget();
widget.classList.toggle('expanded');
saveUiState({ expanded: widget.classList.contains('expanded') });
syncExpandButtonText();
}
function syncExpandButtonText() {
const btn = widget?.querySelector('#cu-expand');
if (!btn || !widget) return;
btn.textContent = widget.classList.contains('expanded') ? 'Collapse' : 'Expand';
}
function setRow(id, text, warn = false) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.classList.toggle('warn', warn);
}
function setStatusDot(state) {
const el = document.getElementById('cu-status-dot');
if (!el) return;
el.className = 'status-dot';
if (state) el.classList.add(state);
}
function renderMini(payload) {
const el5 = document.getElementById('cu-mini-5h');
const elW = document.getElementById('cu-mini-week');
if (!el5 || !elW) return;
const p5 = payload?.fiveHourPercent;
const pw = payload?.weeklyPercent;
el5.innerHTML = `<span class="cu-mini-item-label">5h</span>${compactPercent(p5)}`;
elW.innerHTML = `<span class="cu-mini-item-label">W</span>${compactPercent(pw)}`;
el5.className = 'cu-mini-item';
elW.className = 'cu-mini-item';
const tag5 = percentTag(p5);
const tagW = percentTag(pw);
if (tag5) el5.classList.add(tag5);
if (tagW) elW.classList.add(tagW);
}
function renderPayload(payload) {
ensureWidget();
if (!payload) {
renderMini(null);
setRow('cu-5h', '5h: no data yet');
setRow('cu-week', 'Week: no data yet');
setRow('cu-time', 'Last update: -');
setRow('cu-details', 'Waiting for first background analytics fetch.');
setStatusDot('fail');
return;
}
renderMini(payload);
const fiveWarn = payload.fiveHourPercent != null && payload.fiveHourPercent <= 20;
const weekWarn = payload.weeklyPercent != null && payload.weeklyPercent <= 20;
setRow(
'cu-5h',
`5h: ${payload.fiveHour?.remaining || 'not found'}${payload.fiveHour?.reset ? ` | ${payload.fiveHour.reset}` : ''}`,
fiveWarn
);
setRow(
'cu-week',
`Week: ${payload.weekly?.remaining || 'not found'}${payload.weekly?.reset ? ` | ${payload.weekly.reset}` : ''}`,
weekWarn
);
const ageSec = payload.savedAt ? Math.floor((Date.now() - payload.savedAt) / 1000) : null;
setRow('cu-time', `Last update: ${payload.savedAtText || '-'}${ageSec != null ? ` (${ageSec}s ago)` : ''}`);
setRow('cu-details', `Source: ${payload.source || 'unknown'}`);
setStatusDot('ok');
if (TITLE_PREFIX_ENABLED && payload.fiveHourPercent != null) {
document.title = `[5h ${payload.fiveHourPercent}%] ${document.title.replace(/^\[5h .*?\]\s*/, '')}`;
}
}
function cleanupFrame() {
const old = document.getElementById('codex-analytics-hidden-frame');
if (old) old.remove();
}
function waitForAnalyticsText(frameWindow, timeoutMs) {
return new Promise((resolve, reject) => {
const start = Date.now();
function check() {
try {
const doc = frameWindow.document;
const target = doc.querySelector(TARGET_SELECTOR);
const text = target?.innerText || '';
if (text.trim() && text.includes('5 小时使用限额') && text.includes('每周使用限额')) {
resolve(text);
return;
}
} catch (err) {
reject(err);
return;
}
if (Date.now() - start > timeoutMs) {
reject(new Error('Timed out waiting for analytics content'));
return;
}
setTimeout(check, 500);
}
check();
});
}
async function fetchUsageThroughIframe(force = false) {
if (inFlight && !force) return;
if (document.hidden && !force) return;
inFlight = true;
setStatusDot('busy');
setRow('cu-time', 'Last update: fetching...');
try {
cleanupFrame();
const iframe = document.createElement('iframe');
iframe.id = 'codex-analytics-hidden-frame';
iframe.src = ANALYTICS_URL + '?_ts=' + Date.now();
document.body.appendChild(iframe);
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('Iframe load timeout')), IFRAME_LOAD_TIMEOUT_MS);
iframe.onload = () => {
clearTimeout(t);
resolve();
};
iframe.onerror = () => {
clearTimeout(t);
reject(new Error('Iframe failed to load'));
};
});
const text = await waitForAnalyticsText(iframe.contentWindow, IFRAME_WAIT_MS);
const payload = buildPayloadFromText(text);
savePayload(payload);
renderPayload(payload);
} catch (err) {
const existing = loadPayload();
if (existing) {
renderPayload(existing);
setRow('cu-time', 'Last update: fetch failed, showing cached data');
setStatusDot('fail');
} else {
renderMini(null);
setRow('cu-5h', '5h: fetch failed');
setRow('cu-week', 'Week: fetch failed');
setRow('cu-time', `Last update: ${new Date().toLocaleTimeString()}`);
setRow('cu-details', String(err?.message || err));
setStatusDot('fail');
}
} finally {
cleanupFrame();
inFlight = false;
}
}
function init() {
ensureWidget();
renderPayload(loadPayload());
window.addEventListener('storage', (ev) => {
if (ev.key !== STORAGE_KEY) return;
renderPayload(loadPayload());
});
window.addEventListener('resize', () => {
if (!widget) return;
const rect = widget.getBoundingClientRect();
const pos = clampPosition(rect.left, rect.top);
widget.style.left = `${pos.left}px`;
widget.style.top = `${pos.top}px`;
widget.style.right = 'auto';
widget.style.bottom = 'auto';
saveUiState({ left: pos.left, top: pos.top });
});
setTimeout(() => fetchUsageThroughIframe(false), START_DELAY_MS);
setInterval(() => fetchUsageThroughIframe(false), FETCH_INTERVAL_MS);
}
init();
})();