// ==UserScript== // @name ChatGPT Usage & Credit Watch // @name:zh-CN ChatGPT 用量与额度盯梢 // @namespace https://github.com/malusama/chatgpt-credit-watch // @version 1.0.0 // @description Display ChatGPT usage credits and estimate weekly limits from the current signed-in session. // @description:zh-CN 读取当前 ChatGPT 登录会话的用量数据,展示 credits 并估算周额度。 // @author malusama // @license MIT // @homepageURL https://github.com/malusama/chatgpt-credit-watch // @supportURL https://github.com/malusama/chatgpt-credit-watch/issues // @match https://chatgpt.com/* // @run-at document-start // @sandbox raw // @noframes // @grant none // ==/UserScript== (function () { 'use strict'; const ROOT_ID = 'chatgpt-pro-credit-watch'; const VERSION = '1.0.0'; const USAGE_PATH = '/backend-api/wham/usage'; const DAILY_PATH = '/backend-api/wham/analytics/daily-workspace-usage-counts'; const ANALYTICS_URL = 'https://chatgpt.com/codex/cloud/settings/analytics'; const STORAGE_PLAN = 'chatgpt-pro-credit-watch:plan'; const STORAGE_COLLAPSED = 'chatgpt-pro-credit-watch:collapsed'; const STORAGE_HISTORY = 'chatgpt-pro-credit-watch:history'; const STORAGE_POS = 'chatgpt-pro-credit-watch:pos'; const DAY_MS = 24 * 60 * 60 * 1000; const HOUR_SECONDS = 3600; const WEEK_SECONDS = 7 * 24 * HOUR_SECONDS; const HISTORY_LIMIT = 40; // Self-check thresholds. Estimates can vary around reset boundaries and because used_percent is rounded. const PLAN_BANDS = [ { id: 'plus', label: 'Plus', min: 2500, max: 3000 }, { id: 'pro5x', label: 'Pro 5x', min: 12500, max: 15000 }, { id: 'pro20x', label: 'Pro 20x', min: 50000, max: 60000 }, ]; const capturedDaily = new Map(); let uiReady = false; let latestData = null; const n = (v) => { const x = Number(v); return Number.isFinite(x) ? x : 0; }; const round = (v, d = 2) => Number(n(v).toFixed(d)); const pad2 = (x) => String(x).padStart(2, '0'); const fmtInt = (v) => new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(n(v)); const fmtNum = (v, d = 1) => new Intl.NumberFormat('zh-CN', { maximumFractionDigits: d, minimumFractionDigits: 0 }).format(n(v)); const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[ch]); const ymdUTC = (ms) => { const d = new Date(ms); return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; }; const looksLikeJwt = (s) => typeof s === 'string' && s.length > 100 && s.split('.').length >= 3; function hookNetwork() { const remember = (url, json) => { try { const parsed = new URL(url, location.origin); if (!parsed.pathname.includes('daily-workspace-usage-counts')) return; const start = parsed.searchParams.get('start_date') || ''; const end = parsed.searchParams.get('end_date') || ''; const group = parsed.searchParams.get('group_by') || 'day'; const key = `${start}|${end}|${group}`; capturedDaily.set(key, { url: parsed.pathname + parsed.search, startDate: start, endDate: end, groupBy: group, json, capturedAt: Date.now(), }); if (uiReady) refreshFromNetworkHint(); } catch { // Ignore malformed captured URLs. } }; const rawFetch = window.fetch; if (typeof rawFetch === 'function') { window.fetch = async function hookedFetch(input, init) { const res = await rawFetch.apply(this, arguments); try { const url = typeof input === 'string' ? input : input?.url || ''; if (url.includes('daily-workspace-usage-counts') && res.ok) { res.clone().json().then((json) => remember(url, json)).catch(() => {}); } } catch { // Keep original fetch behavior. } return res; }; } const rawOpen = XMLHttpRequest.prototype.open; const rawSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url) { this.__cwUrl = String(url || ''); return rawOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function () { this.addEventListener('load', () => { if (!String(this.__cwUrl || '').includes('daily-workspace-usage-counts')) return; if (this.status < 200 || this.status >= 300) return; try { remember(this.__cwUrl, JSON.parse(this.responseText)); } catch { // Ignore non-JSON responses. } }); return rawSend.apply(this, arguments); }; } hookNetwork(); function findAccessToken(obj, depth = 0) { if (!obj || typeof obj !== 'object' || depth > 8) return ''; for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string' && /access/i.test(key) && looksLikeJwt(value)) { return value; } if (value && typeof value === 'object') { const found = findAccessToken(value, depth + 1); if (found) return found; } } return ''; } async function getAccessToken() { try { const res = await fetch('/api/auth/session', { credentials: 'include', headers: { accept: 'application/json' }, }); if (!res.ok) return ''; return findAccessToken(await res.json()); } catch { return ''; } } async function apiGet(path, headers) { const res = await fetch(path, { method: 'GET', credentials: 'include', headers, }); if (!res.ok) { const text = await res.text().catch(() => ''); if (res.status === 401) { throw new Error('HTTP 401:当前页面登录态无效。请刷新 chatgpt.com 后重试。'); } throw new Error(`HTTP ${res.status} ${path}\n${text.slice(0, 300)}`); } return res.json(); } function parseWindow(label, w) { const usedPercent = n(w?.used_percent); const windowSeconds = n(w?.limit_window_seconds); const resetAfterSeconds = n(w?.reset_after_seconds); const resetAtMs = n(w?.reset_at) * 1000; return { name: label, usedPercent, windowSeconds, resetAfterSeconds, resetAtMs, windowStartMs: resetAtMs - windowSeconds * 1000, serverNowMs: resetAtMs - resetAfterSeconds * 1000, }; } function collectWindows(usage) { const windows = []; const push = (prefix, rateLimit) => { for (const field of ['primary_window', 'secondary_window']) { const w = rateLimit?.[field]; if (!w) continue; const hours = n(w.limit_window_seconds) / HOUR_SECONDS; const label = hours >= 24 ? `${prefix} ${round(hours / 24, 2)} 天` : `${prefix} ${round(hours, 2)} 小时`; windows.push(parseWindow(label, w)); } }; push('主限制', usage?.rate_limit); for (const item of usage?.additional_rate_limits ?? []) { const name = item.limit_name || item.metered_feature || '额外限制'; push(name, item?.rate_limit); } return windows; } function findWindowByDuration(windows, targetSeconds) { const tolerance = Math.max(HOUR_SECONDS, targetSeconds * 0.05); return windows.find((w) => Math.abs(n(w.windowSeconds) - targetSeconds) <= tolerance); } function parseDaily(json) { return (json?.data ?? []) .slice() .sort((a, b) => String(a.date).localeCompare(String(b.date))) .map((d) => ({ date: d.date, credits: round(n(d.totals?.credits), 4), turns: n(d.totals?.turns), threads: n(d.totals?.threads), })); } function sumCredits(rows) { return round(rows.reduce((s, r) => s + n(r.credits), 0), 4); } function classifyCap(cap) { if (!(cap > 0)) return { band: null, verdict: '无法反推', tone: 'muted' }; let best = null; let bestDist = Infinity; for (const band of PLAN_BANDS) { const mid = (band.min + band.max) / 2; const dist = Math.abs(cap - mid); const slack = Math.max(800, (band.max - band.min) * 0.25); const inside = cap >= band.min - slack && cap <= band.max + slack; if (inside && dist < bestDist) { best = band; bestDist = dist; } } if (!best) { return { band: null, verdict: '不在已知套餐区间', tone: 'bad' }; } if (cap < best.min) { return { band: best, verdict: `偏低,接近 ${best.label} 下限`, tone: 'warn' }; } if (cap > best.max) { return { band: best, verdict: `偏高,超过 ${best.label} 上限`, tone: 'warn' }; } return { band: best, verdict: `落在 ${best.label} 区间`, tone: 'good' }; } function compareToExpected(cap, expectedId) { const expected = PLAN_BANDS.find((p) => p.id === expectedId); if (!expected) return classifyCap(cap); if (!(cap > 0)) return { band: expected, verdict: '无法反推,先用一段时间再查', tone: 'muted' }; if (cap < expected.min * 0.85) { const actual = classifyCap(cap); const actualLabel = actual.band ? actual.band.label : '未知档'; return { band: expected, actualBand: actual.band, verdict: `低于 ${expected.label} 下限(${fmtInt(expected.min)}),更像 ${actualLabel}`, tone: 'bad', }; } if (cap < expected.min) { return { band: expected, verdict: `略低于 ${expected.label} 下限`, tone: 'warn' }; } if (cap > expected.max * 1.2) { return { band: expected, verdict: `高于 ${expected.label} 上限,可能被升档或口径偏差`, tone: 'warn' }; } return { band: expected, verdict: `符合 ${expected.label}`, tone: 'good' }; } function loadHistory() { try { const rows = JSON.parse(localStorage.getItem(STORAGE_HISTORY) || '[]'); return Array.isArray(rows) ? rows : []; } catch { return []; } } function saveHistory(entry) { const rows = loadHistory(); const last = rows[0]; const sameCap = last && Math.abs(n(last.estimatedCap) - n(entry.estimatedCap)) < 50 && Date.now() - n(last.ts) < 3 * HOUR_SECONDS * 1000; if (sameCap) return rows; rows.unshift(entry); localStorage.setItem(STORAGE_HISTORY, JSON.stringify(rows.slice(0, HISTORY_LIMIT))); return rows.slice(0, HISTORY_LIMIT); } function historyDrop(history, currentCap) { const prev = history.find((row) => n(row.estimatedCap) > 0 && Date.now() - n(row.ts) > DAY_MS); if (!prev || !(currentCap > 0) || !(prev.estimatedCap > 0)) return null; const drop = (n(prev.estimatedCap) - currentCap) / n(prev.estimatedCap); if (drop >= 0.15) { return { from: n(prev.estimatedCap), to: currentCap, pct: round(drop * 100, 1), at: prev.ts, }; } return null; } async function fetchDailyRange(startDate, endExclusiveDate, headers) { const key = `${startDate}|${endExclusiveDate}|day`; const cached = capturedDaily.get(key); if (cached?.json) { return { startDate, endDate: endExclusiveDate, url: cached.url, rows: parseDaily(cached.json), source: 'intercept', }; } const qs = new URLSearchParams({ start_date: startDate, end_date: endExclusiveDate, group_by: 'day', }); const path = `${DAILY_PATH}?${qs}`; const json = await apiGet(path, headers); return { startDate, endDate: endExclusiveDate, url: path, rows: parseDaily(json), source: 'request', }; } async function loadData() { const accessToken = await getAccessToken(); const headers = { accept: 'application/json' }; if (accessToken) headers.authorization = `Bearer ${accessToken}`; const usage = await apiGet(USAGE_PATH, headers); const windows = collectWindows(usage); const mainWindows = windows.filter((w) => String(w.name).startsWith('主限制')); const weekly = findWindowByDuration(mainWindows, WEEK_SECONDS) || findWindowByDuration(windows, WEEK_SECONDS); const fiveHour = findWindowByDuration(mainWindows, 5 * HOUR_SECONDS) || findWindowByDuration(windows, 5 * HOUR_SECONDS); const nowMs = weekly?.serverNowMs || fiveHour?.serverNowMs || Date.now(); const today = ymdUTC(nowMs); const endExclusive = ymdUTC(nowMs + DAY_MS); const start7 = ymdUTC(nowMs - 6 * DAY_MS); const start30 = ymdUTC(nowMs - 29 * DAY_MS); const weekStart = weekly ? ymdUTC(weekly.windowStartMs) : start7; const [days7, days30, weekWindow] = await Promise.all([ fetchDailyRange(start7, endExclusive, headers), fetchDailyRange(start30, endExclusive, headers), fetchDailyRange(weekStart, endExclusive, headers), ]); const weekUsedCredits = sumCredits(weekWindow.rows); const usedPercent = n(weekly?.usedPercent); const estimatedCap = usedPercent > 0 ? round(weekUsedCredits / (usedPercent / 100), 1) : 0; const expectedId = localStorage.getItem(STORAGE_PLAN) || 'auto'; const match = expectedId === 'auto' ? classifyCap(estimatedCap) : compareToExpected(estimatedCap, expectedId); const snapshot = { ts: Date.now(), estimatedCap, usedPercent, weekUsedCredits: round(weekUsedCredits, 2), credits7: round(sumCredits(days7.rows), 2), credits30: round(sumCredits(days30.rows), 2), expectedId, verdict: match.verdict, }; const history = saveHistory(snapshot); return { fetchedAt: Date.now(), today, windows, weekly, fiveHour, days7, days30, weekWindow, weekUsedCredits: round(weekUsedCredits, 2), credits7: snapshot.credits7, credits30: snapshot.credits30, estimatedCap, usedPercent, match, expectedId, history, drop: historyDrop(history, estimatedCap), }; } function css() { return ` #${ROOT_ID} { --cw-bg: #fff; --cw-panel: #f7f7f8; --cw-text: #0d0d0d; --cw-muted: #6e6e80; --cw-line: rgba(0,0,0,.1); --cw-good: #0f9d58; --cw-warn: #d97706; --cw-bad: #dc2626; position: fixed; top: 72px; right: 16px; z-index: 2147483000; width: min(420px, calc(100vw - 24px)); color: var(--cw-text); font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, sans-serif; } #${ROOT_ID}[data-theme="dark"] { --cw-bg: #212121; --cw-panel: #2f2f2f; --cw-text: #ececec; --cw-muted: #b4b4b4; --cw-line: rgba(255,255,255,.12); } #${ROOT_ID} * { box-sizing: border-box; } #${ROOT_ID} .cw-shell { border: 1px solid var(--cw-line); border-radius: 12px; background: var(--cw-bg); box-shadow: 0 16px 48px rgba(0,0,0,.18); overflow: hidden; } #${ROOT_ID} .cw-header { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--cw-line); cursor: move; user-select: none; } #${ROOT_ID} .cw-title { flex: 1; min-width: 0; } #${ROOT_ID} .cw-title strong { display: block; font-size: 14px; } #${ROOT_ID} .cw-title span { display: block; color: var(--cw-muted); font-size: 11px; } #${ROOT_ID} button { border: 1px solid var(--cw-line); background: var(--cw-panel); color: var(--cw-text); border-radius: 7px; cursor: pointer; font: inherit; } #${ROOT_ID} .cw-icon { width: 30px; height: 30px; } #${ROOT_ID} .cw-body { padding: 12px; max-height: calc(100vh - 160px); overflow: auto; } #${ROOT_ID}.cw-collapsed .cw-body { display: none; } #${ROOT_ID} .cw-banner { border-radius: 8px; padding: 8px 10px; margin-bottom: 10px; font-size: 12px; } #${ROOT_ID} .cw-banner.good { background: rgba(15,157,88,.12); color: var(--cw-good); } #${ROOT_ID} .cw-banner.warn { background: rgba(217,119,6,.12); color: var(--cw-warn); } #${ROOT_ID} .cw-banner.bad { background: rgba(220,38,38,.12); color: var(--cw-bad); } #${ROOT_ID} .cw-banner.muted { background: var(--cw-panel); color: var(--cw-muted); } #${ROOT_ID} .cw-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } #${ROOT_ID} .cw-card { border: 1px solid var(--cw-line); border-radius: 8px; background: var(--cw-panel); padding: 9px; } #${ROOT_ID} .cw-card.wide { grid-column: 1 / -1; } #${ROOT_ID} .cw-label { color: var(--cw-muted); font-size: 11px; margin-bottom: 3px; } #${ROOT_ID} .cw-value { font-size: 20px; font-weight: 700; } #${ROOT_ID} .cw-sub { margin-top: 3px; color: var(--cw-muted); font-size: 11px; } #${ROOT_ID} select { width: 100%; border: 1px solid var(--cw-line); background: var(--cw-bg); color: var(--cw-text); border-radius: 7px; padding: 6px 8px; font: inherit; } #${ROOT_ID} .cw-tabs { display: flex; gap: 6px; margin: 10px 0 8px; } #${ROOT_ID} .cw-tab { flex: 1; padding: 6px 0; } #${ROOT_ID} .cw-tab.active { outline: 1px solid #10a37f; } #${ROOT_ID} table { width: 100%; border-collapse: collapse; font-size: 12px; } #${ROOT_ID} th, #${ROOT_ID} td { padding: 6px 4px; border-bottom: 1px solid var(--cw-line); text-align: right; } #${ROOT_ID} th:first-child, #${ROOT_ID} td:first-child { text-align: left; } #${ROOT_ID} .cw-error { white-space: pre-wrap; background: rgba(220,38,38,.12); color: var(--cw-bad); border-radius: 8px; padding: 10px; } #${ROOT_ID} .cw-link { display: inline-block; margin-top: 8px; color: #10a37f; font-size: 12px; } `; } function detectTheme() { const bg = getComputedStyle(document.body).backgroundColor; const m = String(bg).match(/rgba?\(([^)]+)\)/); if (!m) return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; const [r, g, b] = m[1].split(',').slice(0, 3).map(Number); const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; return luma < 0.45 ? 'dark' : 'light'; } function getRoot() { let root = document.getElementById(ROOT_ID); if (root) return root; const style = document.createElement('style'); style.textContent = css(); document.documentElement.appendChild(style); root = document.createElement('div'); root.id = ROOT_ID; root.dataset.theme = detectTheme(); if (localStorage.getItem(STORAGE_COLLAPSED) === '1') root.classList.add('cw-collapsed'); root.innerHTML = `
额度盯梢 v${VERSION} 准备读取 ChatGPT 用量
`; document.documentElement.appendChild(root); try { const pos = JSON.parse(localStorage.getItem(STORAGE_POS) || 'null'); if (pos && Number.isFinite(pos.left) && Number.isFinite(pos.top)) { root.style.left = `${pos.left}px`; root.style.top = `${pos.top}px`; root.style.right = 'auto'; } } catch { // Keep the default position. } setupDrag(root); root.querySelector('[data-cw-refresh]').addEventListener('click', () => refresh(root, true)); root.querySelector('[data-cw-collapse]').addEventListener('click', () => { root.classList.toggle('cw-collapsed'); localStorage.setItem(STORAGE_COLLAPSED, root.classList.contains('cw-collapsed') ? '1' : '0'); }); return root; } function setupDrag(root) { const header = root.querySelector('.cw-header'); let dragging = false; let sx = 0; let sy = 0; let sl = 0; let st = 0; header.addEventListener('mousedown', (event) => { if (event.button !== 0 || event.target.closest('button')) return; const rect = root.getBoundingClientRect(); dragging = true; sx = event.clientX; sy = event.clientY; sl = rect.left; st = rect.top; root.style.left = `${sl}px`; root.style.top = `${st}px`; root.style.right = 'auto'; event.preventDefault(); }); document.addEventListener('mousemove', (event) => { if (!dragging) return; root.style.left = `${Math.max(4, sl + event.clientX - sx)}px`; root.style.top = `${Math.max(4, st + event.clientY - sy)}px`; }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; const rect = root.getBoundingClientRect(); localStorage.setItem(STORAGE_POS, JSON.stringify({ left: Math.round(rect.left), top: Math.round(rect.top) })); }); } function rangeTable(rows) { if (!rows.length) return '
这一段还没有 daily credits。
'; const body = rows .slice() .reverse() .map( (r) => ` ${escapeHtml(r.date)} ${fmtNum(r.credits, 2)} ${fmtInt(r.turns)} ` ) .join(''); return ` ${body}
日期Credits轮数
`; } function render(root, data, rangeKey = 'days7') { latestData = data; root.dataset.cwRange = rangeKey; const body = root.querySelector('[data-cw-body]'); const expectedId = localStorage.getItem(STORAGE_PLAN) || 'auto'; const drop = data.drop; const bannerTone = drop ? 'bad' : data.match.tone; const bannerText = drop ? `相对 ${new Date(drop.at).toLocaleDateString()} 的快照,周上限从 ${fmtInt(drop.from)} 降到 ${fmtInt(drop.to)}(-${drop.pct}%)。` : data.match.verdict; const five = data.fiveHour; const week = data.weekly; const range = data[rangeKey] || data.days7; body.innerHTML = `
${escapeHtml(bannerText)}
你认为自己的套餐
反推周上限
${data.estimatedCap ? fmtNum(data.estimatedCap, 1) : '—'}
本周已用 ${fmtNum(data.weekUsedCredits, 2)} credits · 已用 ${fmtNum(data.usedPercent, 1)}%
近 7 天 credits
${fmtNum(data.credits7, 1)}
近 1 个月 credits
${fmtNum(data.credits30, 1)}
5 小时窗口
${five ? fmtNum(five.usedPercent, 1) + '%' : '—'}
${five ? '重置 ' + new Date(five.resetAtMs).toLocaleString() : ''}
7 天窗口
${week ? fmtNum(week.usedPercent, 1) + '%' : '—'}
${week ? '重置 ' + new Date(week.resetAtMs).toLocaleString() : ''}
${escapeHtml(range.startDate)} → ${escapeHtml(range.endDate)} · ${range.source === 'intercept' ? '拦截页面请求' : '主动请求'}
${rangeTable(range.rows)}
自查参考区间
Plus 2500-3000 · Pro 5x 1.25万-1.5万 · Pro 20x 5万-6万
${ data.history.length ? `
本地快照
${data.history .slice(0, 8) .map( (h) => ` ` ) .join('')}
时间周上限7天
${escapeHtml(new Date(h.ts).toLocaleString())} ${fmtNum(h.estimatedCap, 1)} ${fmtNum(h.credits7, 1)}
` : '' } 打开 ChatGPT 用量页
只读当前登录会话的 ChatGPT 网页内部接口,不向第三方上传数据。周上限由「本周 credits / 已用百分比」估算,重置当天会有切桶误差。
`; body.querySelector('[data-cw-plan]').addEventListener('change', (event) => { localStorage.setItem(STORAGE_PLAN, event.target.value); refresh(root, true); }); body.querySelectorAll('[data-cw-range]').forEach((btn) => { btn.addEventListener('click', () => render(root, data, btn.dataset.cwRange)); }); root.querySelector('[data-cw-status]').textContent = `已更新 ${new Date(data.fetchedAt).toLocaleTimeString()}`; } let refreshing = false; async function refresh(root, force) { if (refreshing && !force) return; refreshing = true; const body = root.querySelector('[data-cw-body]'); root.querySelector('[data-cw-status]').textContent = '正在读取用量数据…'; if (!latestData) body.innerHTML = '
正在读取 credits…
'; try { const data = await loadData(); render(root, data, root.dataset.cwRange || 'days7'); } catch (err) { root.querySelector('[data-cw-status]').textContent = '读取失败'; body.innerHTML = `
${escapeHtml(err?.message || err)}
打开 ChatGPT 用量页后再试`; } finally { refreshing = false; } } function refreshFromNetworkHint() { const root = document.getElementById(ROOT_ID); if (root && latestData) refresh(root, false); } function init() { if (document.getElementById(ROOT_ID)) return; const root = getRoot(); uiReady = true; refresh(root, true); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); } else { init(); } })();