Perplexity Pro Counter

Show remaining Pro queries on perplexity.ai

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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