统计ChatGPT官网使用次数的油猴脚本,支持导入导出记录

基于这位大佬的脚本

注:只能本地统计,多地使用无法统计(但可以导入导出使用记录),抛砖引玉!您的使用就是我更新最大的动力 :heart_eyes:

已上传 Greasyfork ChatGPT用量统计





代码太长只能传Greasyfork了 :tieba_087:

25 Likes

3lue佬怎么这么强 :heart_eyes:

2 Likes

claude怎么这么强 :tieba_087:

1 Like

lue佬还是太厉害了 :hugs:

1 Like

太强了!

1 Like

太强了,3lue!

1 Like

太强了 3lue佬 :tieba_087:

1 Like

已上传Greasyfork

1 Like

更新了一版
展示
最新版效果展示:




好好好,看起来更清爽了

简单调了下3lue的代码,现在可以选择常驻展开在边上或者鼠标悬停查看,个人感觉更加简洁一点

1 Like

啊啊太棒了求发

// ==UserScript==
// @name         ChatGPT用量统计(优化)
// @namespace    https://github.com/tizee/tampermonkey-chatgpt-model-usage-monitor
// @version      2.2.0
// @description  优化ChatGPT模型调用量实时统计,折叠按钮改为图标简洁样式,悬停显示详细,用量默认显示配额,并增加窗口类型颜色标注
// @author       tizee (original), schweigen (modified)
// @match        https://chatgpt.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    // --- 常量配置 ---
    const COLORS = {
        primary: "#5E9EFF",
        background: "#1A1B1E",
        surface: "#2A2B2E",
        border: "#363636",
        text: "#E5E7EB",
        secondaryText: "#9CA3AF",
        success: "#10B981",
        warning: "#F59E0B",
        danger: "#EF4444",
        disabled: "#4B5563",
        white: "#E5E7EB",
        gray: "#9CA3AF",

        hour3: "#61DAFB",   // 3h窗口颜色(天蓝)
        daily: "#9F7AEA",   // 24h窗口颜色(紫色)
        weekly: "#10B981",  // 7d窗口颜色(绿色)
    };

    const TIME_WINDOWS = {
        hour3: 3 * 60 * 60 * 1000,
        daily: 24 * 60 * 60 * 1000,
        weekly: 7 * 24 * 60 * 60 * 1000,
    };

    const SPECIAL_MODEL = "gpt-4o-mini";

    // 默认数据结构(与原脚本保持一致)
    const defaultUsageData = {
        position: { x: null, y: null },
        minimized: false,
        models: {
            "gpt-4o": {
                requests: [],
                quota: 80,
                windowType: "hour3"
            },
            "o4-mini": {
                requests: [],
                quota: 300,
                windowType: "daily"
            },
            "o4-mini-high": {
                requests: [],
                quota: 100,
                windowType: "daily"
            },
            "o3": {
                requests: [],
                quota: 100,
                windowType: "weekly"
            },
            "gpt-4": {
                requests: [],
                quota: 40,
                windowType: "hour3"
            },
            "gpt-4-5": {
                requests: [],
                quota: 50,
                windowType: "weekly"
            },
        },
        miniCount: 0,
        progressType: "bar",
        showWindowResetTime: false,
    };

    // --- 数据存储操作 ---
    const Storage = {
        key: "usageData",
        get() {
            let usageData = GM_getValue(this.key, defaultUsageData);
            if (!usageData) usageData = defaultUsageData;

            // 版本兼容及默认属性补充
            usageData.position = usageData.position || { x: null, y: null };
            usageData.minimized = usageData.minimized ?? false;
            usageData.models = usageData.models || {};
            usageData.miniCount = usageData.miniCount || 0;
            usageData.progressType = usageData.progressType || "bar";
            usageData.showWindowResetTime = usageData.showWindowResetTime || false;

            // 清理特殊模型数据
            if (usageData.models[SPECIAL_MODEL]) {
                usageData.miniCount += (usageData.models[SPECIAL_MODEL].requests?.length || 0);
                delete usageData.models[SPECIAL_MODEL];
            }

            GM_setValue(this.key, usageData);
            return usageData;
        },
        set(data) {
            GM_setValue(this.key, data);
        },
        update(callback) {
            const data = this.get();
            callback(data);
            this.set(data);
        }
    };

    let usageData = Storage.get();

    // --- 样式 ---
    GM_addStyle(`
        #chatUsageMonitor {
            position: fixed;
            top: 20px;
            right: 20px;
            background: rgba(26, 27, 30, 0.9);
            border-radius: 12px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.4);
            font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
            color: ${COLORS.text};
            font-size: 14px;
            user-select: none;
            z-index: 999999;
            width: fit-content;
            max-width: 320px;
            overflow: visible;
        }

        #chatUsageMonitor.minimized {
            width: 32px;
            height: 32px;
            border-radius: 16px;
            background: rgba(0, 0, 0, 0.1); /* 更透明的背景 */
            color: var(--text-color, #666); /* 使用系统文本颜色或后备色 */
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            position: fixed;
            backdrop-filter: blur(4px); /* 可选:毛玻璃效果 */
        }
        /* 暗色主题适配 */
        @media (prefers-color-scheme: dark) {
            #chatUsageMonitor.minimized {
                background: rgba(255, 255, 255, 0.1);
                color: var(--text-color, #999);
            }
        }


        #chatUsageMonitor .usage-content {
            padding: 12px 16px;
            display: flex;
            flex-direction: column;
            gap: 8px;
            max-height: 60vh;
            overflow-y: auto;
            pointer-events: auto;
            user-select: none;
        }

        #chatUsageMonitor .model-row {
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 12px;
        }

        #chatUsageMonitor .model-row .label {
            display: flex;
            align-items: center;
            gap: 8px;
            white-space: nowrap;
        }

        #chatUsageMonitor .model-row .label .window-badge {
            padding: 2px 6px;
            border-radius: 8px;
            font-size: 0.75em;
            font-weight: 600;
            color: #fff;
            user-select: none;
        }

        #chatUsageMonitor .window-badge.hour3 {
            background-color: ${COLORS.hour3};
        }

        #chatUsageMonitor .window-badge.daily {
            background-color: ${COLORS.daily};
        }

        #chatUsageMonitor .window-badge.weekly {
            background-color: ${COLORS.weekly};
        }

        #chatUsageMonitor .model-row .usage {
            font-weight: 600;
            font-variant-numeric: tabular-nums;
            color: ${COLORS.success};
        }

        #chatUsageMonitor .toggle-btn {
            background: none;
            border: none;
            color: ${COLORS.secondaryText};
            cursor: pointer;
            font-size: 18px;
            line-height: 1;
            padding: 4px 8px;
            user-select: none;
            transition: color 0.2s ease;
        }
        #chatUsageMonitor .toggle-btn:hover {
            color: ${COLORS.primary};
        }

        #chatUsageMonitor svg {
            fill: none; /*不使用填充,使用stroke*/
            stroke: currentColor; /* 使用当前文本颜色 */
            stroke-width: 2;
            width: 18px;
            height: 18px;
            display: block;
        }

        /* 用量统计容器的动画 显示隐藏 */
        #chatUsageMonitor .usage-content.hide {
            display: block;
            visibility: hidden;
            opacity: 0;
            height: 0;
            overflow: hidden;
            transition: visibility 0.3s, opacity 0.3s, height 0.3s;
        }

        #chatUsageMonitor .usage-content:not(.hide) {
            visibility: visible;
            opacity: 1;
            height: auto;
        }

    `);

    // ---辅助函数---
    // 生成SVG图标 - 时钟
    function createClockIcon() {
        const svgNS = "http://www.w3.org/2000/svg";
        const svg = document.createElementNS(svgNS, "svg");
        svg.setAttribute("viewBox", "0 0 24 24");
        svg.setAttribute("aria-hidden", "true");
        svg.setAttribute("focusable", "false");
        
        const circle = document.createElementNS(svgNS, "circle");
        circle.setAttribute("cx", "12");
        circle.setAttribute("cy", "12");
        circle.setAttribute("r", "10");
        circle.setAttribute("stroke", "currentColor"); // 使用currentColor
        circle.setAttribute("fill", "none");
        circle.setAttribute("stroke-width", "2");

        const line1 = document.createElementNS(svgNS, "line");
        line1.setAttribute("x1", "12");
        line1.setAttribute("y1", "6");
        line1.setAttribute("x2", "12");
        line1.setAttribute("y2", "12");
        line1.setAttribute("stroke", "currentColor"); // 使用currentColor
        line1.setAttribute("stroke-width", "2");
        line1.setAttribute("stroke-linecap", "round");

        const line2 = document.createElementNS(svgNS, "line");
        line2.setAttribute("x1", "12");
        line2.setAttribute("y1", "12");
        line2.setAttribute("x2", "16");
        line2.setAttribute("y2", "14");
        line2.setAttribute("stroke", "currentColor"); // 使用currentColor
        line2.setAttribute("stroke-width", "2");
        line2.setAttribute("stroke-linecap", "round");

        svg.appendChild(circle);
        svg.appendChild(line1);
        svg.appendChild(line2);
        return svg;
    }


    // 清理旧请求,保留对应时间窗口内的请求
    function cleanupExpiredRequests() {
        const now = Date.now();
        const maxWindow = TIME_WINDOWS.weekly; //最长时间窗口

        Object.values(usageData.models).forEach(model => {
            model.requests = model.requests.filter(req => now - req.timestamp < maxWindow);
        });
    }

    // --- 主UI逻辑 ---

    let monitorDiv = null;
    let usageContentDiv = null;
    let minimized = true;
    let isPinnedOpen = false; // 是否点击后持续展开

    // 更新统计信息显示(刷新)
    function updateUsageDisplay() {
        if (!usageContentDiv) return;
        usageContentDiv.innerHTML = ""; //清空内容

        const now = Date.now();

        // 按窗口类型排序顺序
        const windowOrder = ["hour3", "daily", "weekly"];

        // 从models中筛选有quota且可用的模型,并排序
        let modelsList = Object.entries(usageData.models);

        // 默认展示所有可用额度,故用的是quota,不过滤请求数为0的
        // 这样不会遗漏配额为0的模型(无限额显示∞)
        modelsList.sort(([k1, m1], [k2, m2]) => {
            const aOrder = windowOrder.indexOf(m1.windowType);
            const bOrder = windowOrder.indexOf(m2.windowType);
            if (aOrder !== bOrder) return aOrder - bOrder;
            // 同窗口类型内按key字母序
            return k1.localeCompare(k2);
        });

        modelsList.forEach(([key, model]) => {
            const windowMs = TIME_WINDOWS[model.windowType];
            const count = model.requests.filter(req => now - req.timestamp < windowMs).length;

            // 构造一行显示元素
            const row = document.createElement("div");
            row.className = "model-row";

            // 左侧显示label和窗口badge
            const labelSpan = document.createElement("span");
            labelSpan.className = "label";

            // 彩色圆点(取对应颜色)
            const dot = document.createElement("span");
            dot.style.display = "inline-block";
            dot.style.width = "10px";
            dot.style.height = "10px";
            dot.style.borderRadius = "50%";
            dot.style.backgroundColor = COLORS[model.windowType] || COLORS.gray;
            dot.style.marginRight = "6px";
            labelSpan.appendChild(dot);

            // 模型名文本
            const modelNameText = document.createElement("span");
            modelNameText.textContent = key;
            labelSpan.appendChild(modelNameText);

            // 窗口类型badge
            const windowBadge = document.createElement("span");
            windowBadge.className = "window-badge " + model.windowType;
            windowBadge.textContent = model.windowType === "hour3" ? "3h" : (model.windowType === "daily" ? "24h" : "7d");
            labelSpan.appendChild(windowBadge);

            row.appendChild(labelSpan);

            // 右侧显示用量
            const usageSpan = document.createElement("span");
            usageSpan.className = "usage";

            // quota为0 视为无限制
            const quotaDisplay = model.quota > 0 ? model.quota : "∞";

            // 默认显示 格式:已用 / 配额
            usageSpan.textContent = `${count}/${quotaDisplay}`;

            row.appendChild(usageSpan);

            usageContentDiv.appendChild(row);
        });

        // 针对特殊模型 gpt-4o-mini
        if (usageData.miniCount && usageData.miniCount > 0) {
            const specialRow = document.createElement("div");
            specialRow.className = "model-row";

            const labelSpan = document.createElement("span");
            labelSpan.className = "label";

            const italicName = document.createElement("span");
            italicName.textContent = SPECIAL_MODEL;
            italicName.style.fontStyle = "italic";
            italicName.style.color = COLORS.secondaryText;
            labelSpan.appendChild(italicName);

            specialRow.appendChild(labelSpan);

            const usageSpan = document.createElement("span");
            usageSpan.className = "usage";
            usageSpan.textContent = `${usageData.miniCount} 次`;
            usageSpan.style.fontStyle = "normal";

            specialRow.appendChild(usageSpan);

            usageContentDiv.appendChild(specialRow);
        }

        // 空状态提示(所有模型空)
        if (usageContentDiv.childElementCount === 0) {
            const emptyEl = document.createElement("div");
            emptyEl.style.color = COLORS.secondaryText;
            emptyEl.style.textAlign = "center";
            emptyEl.style.fontStyle = "italic";
            emptyEl.textContent = "使用模型后才会显示用量统计。";
            usageContentDiv.appendChild(emptyEl);
        }
    }

    // 创建监控器UI(主容器)
    function createMonitorUI() {
        if (monitorDiv) return; // 已创建直接返回

        monitorDiv = document.createElement("div");
        monitorDiv.id = "chatUsageMonitor";
        monitorDiv.title = "用量统计";

        // 初始化为折叠状态
        minimized = true;
        updateMinimizedState();

        // 内容区域,显示用量统计详情
        usageContentDiv = document.createElement("div");
        usageContentDiv.className = "usage-content hide"; // 初始隐藏(折叠时隐藏)
        monitorDiv.appendChild(usageContentDiv);

        // 图标按钮(时钟)
        const icon = createClockIcon();
        monitorDiv.appendChild(icon);

        // 事件 - 鼠标悬停显示内容,移出隐藏(非点击固定展开时)
        monitorDiv.addEventListener("mouseenter", () => {
            if (!isPinnedOpen) {
                expandPanel();
            }
        });
        monitorDiv.addEventListener("mouseleave", () => {
            if (!isPinnedOpen) {
                collapsePanel();
            }
        });

        // 点击按钮切换固定展开/折叠
        monitorDiv.addEventListener("click", (e) => {
            // 如果拖拽中,忽略点击
            if (dragging) return;

            isPinnedOpen = !isPinnedOpen;
            if (isPinnedOpen) {
                expandPanel(true);
            } else {
                collapsePanel(true);
            }
        });

        // 可拖拽
        setupDraggable(monitorDiv);

        document.body.appendChild(monitorDiv);

        updateUsageDisplay();
    }

    // 更新折叠状态UI表现
    function updateMinimizedState() {
        if (!monitorDiv) return;

        if (minimized) {
            monitorDiv.classList.add("minimized");
            if (usageContentDiv) usageContentDiv.classList.add("hide");
        } else {
            monitorDiv.classList.remove("minimized");
            if (usageContentDiv) usageContentDiv.classList.remove("hide");
        }
    }

    // 展开面板(显示详细信息)
    function expandPanel(pin = false) {
        minimized = false;
        if (pin) isPinnedOpen = true;
        updateMinimizedState();
        updateUsageDisplay();
    }

    // 折叠面板(隐藏详细信息)
    function collapsePanel(pin = false) {
        minimized = true;
        if (pin) isPinnedOpen = false;
        updateMinimizedState();
    }

    // --- 拖拽支持 ---
    // 拖拽时忽略点击防止误操作展开/折叠
    let dragging = false;
    function setupDraggable(element) {
        let startX, startY, origX, origY;

        element.style.position = "fixed";
        element.style.top = usageData.position.y !== null ? usageData.position.y + "px" : null;
        element.style.left = usageData.position.x !== null ? usageData.position.x + "px" : null;
        element.style.right = "20px";
        element.style.top = usageData.position.y !== null ? usageData.position.y + "px" : "20px";

        element.addEventListener("mousedown", (e) => {
            // 只允许左键拖拽
            if (e.button !== 0) return;

            // 禁止对input/select/button等控件拖拽
            const tag = e.target.tagName.toLowerCase();
            if (["input", "select", "button", "textarea"].includes(tag)) return;

            dragging = false;
            startX = e.clientX;
            startY = e.clientY;

            const rect = element.getBoundingClientRect();
            origX = rect.left;
            origY = rect.top;

            function onMouseMove(ev) {
                const deltaX = ev.clientX - startX;
                const deltaY = ev.clientY - startY;

                if (!dragging) {
                    if (Math.abs(deltaX) >= 5 || Math.abs(deltaY) >= 5) {
                        dragging = true;
                    } else {
                        return; //忽略小移动
                    }
                }

                let newX = origX + deltaX;
                let newY = origY + deltaY;

                // 限制边界防止跑出视口
                newX = Math.min(Math.max(0, newX), window.innerWidth - rect.width);
                newY = Math.min(Math.max(0, newY), window.innerHeight - rect.height);

                element.style.left = newX + "px";
                element.style.top = newY + "px";
                element.style.right = "auto";
                element.style.bottom = "auto";
            }

            function onMouseUp(ev) {
                document.removeEventListener("mousemove", onMouseMove);
                document.removeEventListener("mouseup", onMouseUp);

                if (dragging) {
                    dragging = false;
                    // 保存位置
                    const rect = element.getBoundingClientRect();
                    usageData.position = { x: rect.left, y: rect.top };
                    Storage.set(usageData);
                }
            }

            document.addEventListener("mousemove", onMouseMove);
            document.addEventListener("mouseup", onMouseUp);

            e.preventDefault();
        });
    }

    // --- 监听和刷新逻辑 ---
    // 清理过期记录
    function periodicCleanup() {
        cleanupExpiredRequests();
        Storage.set(usageData);
    }

    // 刷新UI显示
    function periodicUpdate() {
        usageData = Storage.get();
        if (monitorDiv && !minimized) {
            updateUsageDisplay();
        }
    }

    // --- 用于脚本初始化 ---

    function initialize() {
        usageData = Storage.get();
        cleanupExpiredRequests();
        Storage.set(usageData);

        createMonitorUI();

        // 初始化时默认折叠,显示图标
        minimized = true;
        isPinnedOpen = false;
        updateMinimizedState();

        const posX = usageData.position.x;
        const posY = usageData.position.y;
        if (posX !== null && posY !== null) {
            element.style.left = posX + "px";
            element.style.top = posY + "px";
            element.style.right = "auto";
            element.style.bottom = "auto";
        } else {
            // 默认靠右上。但不要写right/top强制覆盖
            element.style.top = "20px";
            element.style.right = "20px";
        }
        

        // 周期刷新数据和UI
        setInterval(() => {
            periodicCleanup();
            periodicUpdate();
        }, 60 * 1000);
    }

    // --- 拦截fetch,监测模型使用 ---
    const targetWindow = typeof unsafeWindow === "undefined" ? window : unsafeWindow;
    if (!targetWindow._fetchWrappedUsageMonitor) { 
        const originalFetch = targetWindow.fetch;
        targetWindow.fetch = new Proxy(originalFetch, {
            apply: async (target, thisArg, args) => {
                const response = await target.apply(thisArg, args);
                try {
                    const [reqInfo, reqInit] = args;
                    const fetchUrl = typeof reqInfo === "string" ? reqInfo : reqInfo?.href;

                    if (reqInit?.method === "POST" && fetchUrl?.endsWith("/conversation")) {
                        const bodyText = reqInit.body;
                        if (bodyText) {
                            const bodyObj = JSON.parse(bodyText);
                            if (bodyObj?.model) {
                                const modelId = bodyObj.model;

                                // 特殊模型计数
                                if (modelId === SPECIAL_MODEL) {
                                    usageData.miniCount = (usageData.miniCount || 0) + 1;
                                    Storage.set(usageData);
                                    updateUsageDisplay();
                                } else {
                                    // 代理请求计数,保存时间戳
                                    if (!usageData.models[modelId]) {
                                        usageData.models[modelId] = {
                                            requests: [],
                                            quota: 50,
                                            windowType: "daily",
                                        };
                                    }
                                    // 清理旧请求,防止爆炸
                                    cleanupExpiredRequests();

                                    usageData.models[modelId].requests.push({ timestamp: Date.now() });

                                    Storage.set(usageData);
                                    updateUsageDisplay();
                                }
                            }
                        }
                    }
                } catch (e) {
                    console.warn("[UsageMonitor] fetch hook failed:", e);
                }
                return response;
            }
        });
        targetWindow._fetchWrappedUsageMonitor = true;
    }

    // DOM内容加载完成初始化
    if (document.readyState === "loading") {
        targetWindow.addEventListener("DOMContentLoaded", initialize);
    } else {
        setTimeout(initialize, 300);
    }

    // 页面路由切换重新初始化
    window.addEventListener("popstate", () => setTimeout(initialize, 300));

    // 观察DOM删除状态,若元素被删除,自动重建
    const observer = new MutationObserver(() => {
        if (!document.getElementById("chatUsageMonitor")) {
            setTimeout(() => {
                usageData = Storage.get();
                createMonitorUI();
            }, 300);
        }
    });
    observer.observe(document.documentElement || document.body, {
        childList: true,
        subtree: true,
    });

})();

1 Like

谢谢!!

再调了一下,但是我希望的时钟图标还是没出来 :tieba_087:

1 Like

更新了一下,已上传Greasyfork

非常nice,支持

1 Like

感谢~~

更新了,支持导入、导出、合并记录!

太goood了

1 Like