Perplexity Pro Counter

Show remaining Pro queries on perplexity.ai

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         Perplexity Pro Counter
// @namespace    https://www.perplexity.ai/
// @version      1.0.1
// @description  Show remaining Pro queries on perplexity.ai
// @match        https://www.perplexity.ai/*
// @run-at       document-end
// @grant        none
// @license      MIT
// ==/UserScript==

(() => {
  'use strict';

  const API_URL = 'https://www.perplexity.ai/rest/rate-limit/all';
  const STORAGE_KEY = 'copilot-perplexity-pro-counter';
  const WIDGET_ID = 'copilot-pro-counter-widget';
  const LABEL_ID = 'copilot-pro-counter-value';
  const HINT_ID = 'copilot-pro-counter-hint';
  const REFRESH_ID = 'copilot-pro-counter-refresh';
  const HEADER_ID = 'copilot-pro-counter-header';
  const BODY_ID = 'copilot-pro-counter-body';

  if (document.getElementById(WIDGET_ID)) {
    return;
  }

  const getTheme = () => {
    const bodyStyle = window.getComputedStyle(document.body);
    const bodyBackground = bodyStyle.backgroundColor;
    const bodyColor = bodyStyle.color;
    const isDark = (() => {
      const match = bodyBackground.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
      if (!match) {
        return window.matchMedia('(prefers-color-scheme: dark)').matches;
      }

      const red = Number(match[1]);
      const green = Number(match[2]);
      const blue = Number(match[3]);
      return (red * 299 + green * 587 + blue * 114) / 1000 < 140;
    })();

    if (isDark) {
      return {
        background: 'rgba(18, 20, 26, 0.92)',
        border: 'rgba(255, 255, 255, 0.10)',
        text: bodyColor || 'rgba(255, 255, 255, 0.94)',
        muted: 'rgba(255, 255, 255, 0.68)',
        chip: 'rgba(255, 255, 255, 0.08)',
        chipText: 'rgba(255, 255, 255, 0.96)',
        shadow: '0 16px 36px rgba(0, 0, 0, 0.30)',
      };
    }

    return {
      background: 'rgba(255, 255, 255, 0.92)',
      border: 'rgba(17, 24, 39, 0.08)',
      text: bodyColor || 'rgb(17, 24, 39)',
      muted: 'rgba(17, 24, 39, 0.64)',
      chip: 'rgba(17, 24, 39, 0.06)',
      chipText: 'rgb(17, 24, 39)',
      shadow: '0 18px 42px rgba(15, 23, 42, 0.16)',
    };
  };

  const theme = getTheme();

  const widget = document.createElement('div');
  widget.id = WIDGET_ID;
  widget.setAttribute('role', 'status');
  widget.setAttribute('aria-live', 'polite');
  widget.style.cssText = [
    'position: fixed',
    'right: 16px',
    'bottom: 16px',
    'z-index: 2147483647',
    'display: flex',
    'flex-direction: column',
    'width: 250px',
    'overflow: hidden',
    'border-radius: 18px',
    `border: 1px solid ${theme.border}`,
    `background: ${theme.background}`,
    `box-shadow: ${theme.shadow}`,
    'backdrop-filter: blur(18px)',
    'color: inherit',
    'font: inherit',
    'line-height: 1.2',
    'user-select: none',
    'max-width: calc(100vw - 32px)',
  ].join('; ');

  widget.innerHTML = `
    <div id="${HEADER_ID}" style="display:flex;align-items:center;gap:10px;padding:12px 12px 10px 12px;cursor:grab;touch-action:none;-webkit-touch-callout:none;">
      <div style="width:24px;height:24px;display:grid;place-items:center;border-radius:8px;background:${theme.chip};color:${theme.muted};flex:0 0 auto;font-size:14px;line-height:1;">⋮⋮</div>
      <div style="display:flex;flex-direction:column;gap:3px;min-width:0;flex:1 1 auto;">
        <div style="display:flex;align-items:baseline;gap:8px;min-width:0;">
          <div style="font-size:13px;font-weight:600;letter-spacing:0.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#ffffff;">Pro 查询剩余</div>
          <div id="${LABEL_ID}" style="margin-left:auto;display:flex;align-items:baseline;gap:6px;padding:4px 10px;border-radius:999px;background:${theme.chip};color:${theme.chipText};border:1px solid ${theme.border};white-space:nowrap;box-shadow:inset 0 1px 0 rgba(255,255,255,0.04);">
            <span style="font-size:11px;font-weight:500;opacity:0.78;letter-spacing:0.02em;">剩余</span>
            <span data-value style="font-size:14px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;">加载中...</span>
          </div>
        </div>
        <div style="font-size:12px;color:${theme.muted};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">来自 /rest/rate-limit/all</div>
      </div>
      <button id="${REFRESH_ID}" type="button" aria-label="手动刷新" style="width:32px;height:28px;border:0;border-radius:10px;background:${theme.chip};color:#ffffff;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;font-size:12px;font-weight:600;">↻</button>
    </div>
    <div id="${BODY_ID}" style="display:flex;flex-direction:column;gap:8px;padding:0 12px 12px 12px;">
      <div id="${HINT_ID}" style="font-size:13px;color:${theme.muted};user-select:text;">正在读取额度...</div>
    </div>
  `;

  const mount = () => {
    document.body.appendChild(widget);
  };

  if (document.body) {
    mount();
  } else {
    document.addEventListener('DOMContentLoaded', mount, { once: true });
  }

  const valueEl = widget.querySelector(`#${LABEL_ID}`);
  const valueTextEl = valueEl ? valueEl.querySelector('[data-value]') : null;
  const hintEl = widget.querySelector(`#${HINT_ID}`);
  const refreshEl = widget.querySelector(`#${REFRESH_ID}`);
  const headerEl = widget.querySelector(`#${HEADER_ID}`);

  const readState = () => {
    try {
      return JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '{}');
    } catch {
      return {};
    }
  };

  const writeState = (nextState) => {
    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextState));
  };

  const applyPosition = (left, top) => {
    widget.style.left = `${left}px`;
    widget.style.top = `${top}px`;
    widget.style.right = 'auto';
    widget.style.bottom = 'auto';
  };

  const clampPosition = (left, top) => {
    const rect = widget.getBoundingClientRect();
    const maxLeft = Math.max(0, window.innerWidth - rect.width - 8);
    const maxTop = Math.max(0, window.innerHeight - rect.height - 8);

    return {
      left: Math.min(Math.max(8, left), maxLeft),
      top: Math.min(Math.max(8, top), maxTop),
    };
  };

  const state = readState();
  const savedLeft = Number.isFinite(state.left) ? state.left : null;
  const savedTop = Number.isFinite(state.top) ? state.top : null;

  if (savedLeft !== null && savedTop !== null) {
    applyPosition(savedLeft, savedTop);
  }

  const setValue = (text) => {
    if (valueTextEl) {
      valueTextEl.textContent = text;
    } else if (valueEl) {
      valueEl.textContent = text;
    }
  };

  const setHint = (text) => {
    if (hintEl) {
      hintEl.textContent = text;
    }
  };

  const fetchRemaining = async () => {
    try {
      const response = await fetch(API_URL, {
        credentials: 'include',
        headers: {
          'accept': 'application/json',
        },
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const data = await response.json();
      const remaining = data?.remaining_pro;

      if (typeof remaining === 'number' || typeof remaining === 'string') {
        setValue(String(remaining));
        setHint('');
      } else {
        setValue('未找到 remaining_pro');
        setHint('接口已返回,但没有识别到字段');
      }
    } catch (error) {
      setValue('读取失败');
      setHint('请确认已登录并允许读取接口');
      console.error('[Perplexity Pro Counter] Failed to load rate limit data:', error);
    }
  };

  if (refreshEl) {
    refreshEl.addEventListener('click', (event) => {
      event.preventDefault();
      event.stopPropagation();
      refreshEl.setAttribute('disabled', 'true');
      refreshEl.style.opacity = '0.7';
      refreshEl.style.cursor = 'wait';
      fetchRemaining().finally(() => {
        refreshEl.removeAttribute('disabled');
        refreshEl.style.opacity = '1';
        refreshEl.style.cursor = 'pointer';
      });
    });
  }

  if (headerEl) {
    let dragging = false;
    let pointerId = null;
    let startX = 0;
    let startY = 0;
    let startLeft = 0;
    let startTop = 0;

    const onPointerMove = (event) => {
      if (!dragging || event.pointerId !== pointerId) {
        return;
      }

      const deltaX = event.clientX - startX;
      const deltaY = event.clientY - startY;
      const next = clampPosition(startLeft + deltaX, startTop + deltaY);
      applyPosition(next.left, next.top);
    };

    const endDrag = (event) => {
      if (!dragging || (event && event.pointerId !== pointerId)) {
        return;
      }

      dragging = false;
      headerEl.style.cursor = 'grab';

      const nextLeft = widget.style.left ? Number.parseFloat(widget.style.left) : startLeft;
      const nextTop = widget.style.top ? Number.parseFloat(widget.style.top) : startTop;
      writeState({
        left: nextLeft,
        top: nextTop,
      });

      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', endDrag);
      window.removeEventListener('pointercancel', endDrag);

      try {
        headerEl.releasePointerCapture(pointerId);
      } catch (e) { }

      pointerId = null;
    };

    headerEl.addEventListener('pointerdown', (event) => {
      if (event.target === refreshEl || event.button !== 0) {
        return;
      }

      dragging = true;
      pointerId = event.pointerId;
      try {
        headerEl.setPointerCapture(pointerId);
      } catch (e) { }

      const rect = widget.getBoundingClientRect();
      startX = event.clientX;
      startY = event.clientY;
      startLeft = rect.left;
      startTop = rect.top;
      headerEl.style.cursor = 'grabbing';
      window.addEventListener('pointermove', onPointerMove);
      window.addEventListener('pointerup', endDrag);
      window.addEventListener('pointercancel', endDrag);
      event.preventDefault();
    });

    headerEl.addEventListener('contextmenu', (event) => {
      if (event.target !== refreshEl) {
        event.preventDefault();
      }
    });
  }

  fetchRemaining();
})();