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.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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