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.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();