ChatGPT Codex Usage Background Sync Widget

Load Codex analytics in a hidden iframe and show compact draggable 5h/weekly usage on any chatgpt.com page.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==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();
})();