// ==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 = `
`;
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 `
`;
}
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
? `本地快照
| 时间 | 周上限 | 7天 |
${data.history
.slice(0, 8)
.map(
(h) => `
| ${escapeHtml(new Date(h.ts).toLocaleString())} |
${fmtNum(h.estimatedCap, 1)} |
${fmtNum(h.credits7, 1)} |
`
)
.join('')}
`
: ''
}
打开 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();
}
})();