Greasy Fork

Greasy Fork is available in English.

Linux.do Credit Display

显示 linux.do 今日积分变化,每半小时自动刷新,双击清除缓存

当前为 2026-03-10 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Linux.do Credit Display
// @namespace    http://tampermonkey.net/
// @version      2.9
// @description  显示 linux.do 今日积分变化,每半小时自动刷新,双击清除缓存
// @author       You
// @match        https://linux.do/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      credit.linux.do
// ==/UserScript==

(function() {
    'use strict';

    // API 和页面配置
    const TRANSACTIONS_API = 'https://credit.linux.do/api/v1/order/transactions';
    const LEADERBOARD_URL = 'https://linux.do/leaderboard';

    // 缓存 key
    const STORAGE_KEY = 'linux_do_credit_cache';
    const CURRENT_SCORE_KEY = 'linux_do_current_score_cache';
    const POSITION_KEY = 'linux_do_credit_position';

    // 请求配置
    const PAGE_SIZE = 20;
    const IFRAME_TIMEOUT = 15000;
    const ELEMENT_WAIT_TIMEOUT = 5000;
    const AUTO_REFRESH_INTERVAL = 30 * 60 * 1000;

    // DOM 选择器
    const SELECTORS = {
        USER_SCORE: '.user.-self .user__score',
        NUMBER_TITLE: '.number[title]'
    };

    // 延迟时间
    const DELAYS = {
        CLICK_DEBOUNCE: 250,
        IFRAME_INITIAL_WAIT: 500
    };

    // 通用容器样式
    const CONTAINER_STYLE = `
        position: fixed;
        bottom: 20px;
        right: 20px;
        background: #fff;
        color: #333;
        padding: 10px 14px;
        border-radius: 8px;
        font-size: 14px;
        z-index: 9999;
        box-shadow: 0 2px 12px rgba(0,0,0,0.15);
        white-space: nowrap;
        cursor: pointer;
    `;

    // 格式化日期为 YYYY-MM-DD
    function formatDate(date) {
        const y = date.getFullYear();
        const m = String(date.getMonth() + 1).padStart(2, '0');
        const d = String(date.getDate()).padStart(2, '0');
        return `${y}-${m}-${d}`;
    }

    // 获取本地时区偏移字符串(如 "+08:00")
    function getTimezoneOffset() {
        const offset = -new Date().getTimezoneOffset();
        const sign = offset >= 0 ? '+' : '-';
        const abs = Math.abs(offset);
        const h = String(Math.floor(abs / 60)).padStart(2, '0');
        const m = String(abs % 60).padStart(2, '0');
        return `${sign}${h}:${m}`;
    }

    // 获取时间范围(一周前到两日后)
    function getTodayRange() {
        const now = Date.now();
        const tz = getTimezoneOffset();
        const start = formatDate(new Date(now - 7 * 86400000));
        const end = formatDate(new Date(now + 2 * 86400000));
        return {
            startTime: `${start}T00:00:00${tz}`,
            endTime: `${end}T23:59:59${tz}`
        };
    }

    // 从 remark 解析积分: "社区积分从 1877 更新到 1938,变化 61" -> 1938
    function parseScore(remark) {
        const match = remark.match(/更新到\s*(\d+)/);
        if (!match) return null;
        const score = parseInt(match[1], 10);
        return isNaN(score) ? null : score;
    }

    // 安全解析数字文本(移除逗号,如 "2,003" -> 2003)
    function parseNumberText(text) {
        if (!text) return null;
        const num = parseInt(text.trim().replace(/,/g, ''), 10);
        return isNaN(num) ? null : num;
    }

    // 解析紧凑积分文本(如 "2.1k" -> 2100, "2k" -> 2000)
    function parseCompactNumberText(text) {
        if (!text) return null;
        const t = text.replace(/\u00A0/g, ' ').trim();
        const multipliers = { k: 1000, m: 1000000 };

        // 精确匹配:纯数字 / 带 k/m
        let match = t.replace(/,/g, '').match(/^(\d+(?:\.\d+)?)\s*([kKmM])?$/);
        if (!match) {
            // 文本中提取:如 "2.1k" 混在其它字符里
            match = t.match(/(\d+(?:\.\d+)?)\s*([kKmM])\b/);
        }

        if (match) {
            const value = parseFloat(match[1]);
            if (!isFinite(value)) return null;
            const unit = match[2]?.toLowerCase();
            const result = Math.round(value * (multipliers[unit] || 1));
            return isNaN(result) ? null : result;
        }

        // 回退:提取第一个整数(支持逗号)
        match = t.match(/(\d[\d,]*)/);
        return match ? parseNumberText(match[1]) : null;
    }

    // 通用缓存读取(当天有效)
    function getCache(key) {
        const cache = GM_getValue(key, null);
        return (cache && cache.date === new Date().toDateString()) ? cache.score : null;
    }

    // 通用缓存写入
    function setCache(key, score) {
        GM_setValue(key, { score, date: new Date().toDateString() });
    }

    // 从排行榜页面 DOM 获取当前积分
    function parseScoreFromElement(userEl) {
        if (!userEl) return null;
        const numberSpan = userEl.querySelector?.(SELECTORS.NUMBER_TITLE);
        if (numberSpan) {
            const fromTitle = parseNumberText(numberSpan.getAttribute('title'));
            if (fromTitle !== null) return fromTitle;
        }
        return parseCompactNumberText(userEl.textContent);
    }

    // 判断是否在排行榜页面
    function isLeaderboardPage() {
        return location.pathname === '/leaderboard' || location.pathname === '/leaderboard/';
    }

    // 获取今日基础积分
    function fetchBaseScore() {
        return new Promise((resolve, reject) => {
            const { startTime, endTime } = getTodayRange();
            GM_xmlhttpRequest({
                method: 'POST',
                url: TRANSACTIONS_API,
                headers: {
                    'Content-Type': 'application/json',
                    'Origin': 'https://credit.linux.do',
                    'Referer': 'https://credit.linux.do/'
                },
                data: JSON.stringify({ page: 1, page_size: PAGE_SIZE, startTime, endTime, types: ["community"] }),
                withCredentials: true,
                anonymous: false,
                onload: (res) => {
                    try {
                        if (res.status === 401 || res.status === 403) {
                            return reject(new Error('未登录或无权限'));
                        }
                        if (res.status < 200 || res.status >= 300) {
                            return reject(new Error(`请求失败: ${res.status}`));
                        }
                        const orders = JSON.parse(res.responseText).data?.orders || [];
                        for (const item of orders) {
                            if (item.remark?.includes('社区积分')) {
                                const score = parseScore(item.remark);
                                if (score !== null) return resolve(score);
                            }
                        }
                        reject(new Error('未找到积分记录'));
                    } catch (e) {
                        reject(e);
                    }
                },
                onerror: reject
            });
        });
    }

    // 等待元素出现(在指定文档中)
    function waitForElement(doc, selector, timeout = ELEMENT_WAIT_TIMEOUT) {
        return new Promise((resolve) => {
            const el = doc.querySelector(selector);
            if (el) return resolve(el);

            let resolved = false;
            const observer = new MutationObserver(() => {
                if (resolved) return;
                const el = doc.querySelector(selector);
                if (el) {
                    resolved = true;
                    observer.disconnect();
                    resolve(el);
                }
            });
            observer.observe(doc.body, { childList: true, subtree: true });

            setTimeout(() => {
                if (!resolved) {
                    resolved = true;
                    observer.disconnect();
                    resolve(null);
                }
            }, timeout);
        });
    }

    // 通过隐藏 iframe 获取排行榜积分
    function fetchScoreViaIframe() {
        return new Promise((resolve, reject) => {
            const iframe = document.createElement('iframe');
            iframe.style.cssText = 'position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;';
            iframe.src = LEADERBOARD_URL;

            let resolved = false;

            const finish = (success, value) => {
                if (resolved) return;
                resolved = true;
                if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
                success ? resolve(value) : reject(value);
            };

            iframe.onload = async () => {
                let iframeDoc;
                try {
                    iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
                } catch (e) {
                    return finish(false, new Error('无法访问 iframe 内容(跨域限制)'));
                }

                try {
                    await new Promise(r => setTimeout(r, DELAYS.IFRAME_INITIAL_WAIT));
                    const userEl = await waitForElement(iframeDoc, SELECTORS.USER_SCORE);
                    const score = parseScoreFromElement(userEl);
                    finish(score !== null, score ?? new Error('无法找到积分元素'));
                } catch (e) {
                    finish(false, e);
                }
            };

            iframe.onerror = () => finish(false, new Error('iframe 加载失败'));
            setTimeout(() => finish(false, new Error('获取积分超时')), IFRAME_TIMEOUT);
            document.body.appendChild(iframe);
        });
    }

    // 创建基础容器(含拖拽支持)
    function createContainer() {
        const container = document.createElement('div');
        container.id = 'linux-do-credit';
        container.style.cssText = CONTAINER_STYLE;

        // 恢复保存的位置
        const savedPos = GM_getValue(POSITION_KEY, null);
        if (savedPos) {
            container.style.right = 'auto';
            container.style.bottom = 'auto';
            container.style.left = savedPos.x + 'px';
            container.style.top = savedPos.y + 'px';
        }

        // 拖拽状态
        let isDragging = false;
        let hasMoved = false;
        let startX, startY, initialX, initialY;

        const onMove = (e) => {
            if (!isDragging) return;
            const touch = e.touches ? e.touches[0] : e;
            const dx = touch.clientX - startX;
            const dy = touch.clientY - startY;
            if (Math.abs(dx) > 5 || Math.abs(dy) > 5) hasMoved = true;
            if (hasMoved) {
                e.preventDefault();
                const rect = container.getBoundingClientRect();
                const newX = Math.max(0, Math.min(window.innerWidth - rect.width, initialX + dx));
                const newY = Math.max(0, Math.min(window.innerHeight - rect.height, initialY + dy));
                container.style.right = 'auto';
                container.style.bottom = 'auto';
                container.style.left = newX + 'px';
                container.style.top = newY + 'px';
            }
        };

        const onEnd = () => {
            if (!isDragging) return;
            isDragging = false;
            container.style.transition = '';
            if (hasMoved) {
                const rect = container.getBoundingClientRect();
                GM_setValue(POSITION_KEY, { x: rect.left, y: rect.top });
            }
            document.removeEventListener('mousemove', onMove);
            document.removeEventListener('touchmove', onMove);
            document.removeEventListener('mouseup', onEnd);
            document.removeEventListener('touchend', onEnd);
        };

        const onStart = (e) => {
            const touch = e.touches ? e.touches[0] : e;
            isDragging = true;
            hasMoved = false;
            startX = touch.clientX;
            startY = touch.clientY;
            const rect = container.getBoundingClientRect();
            initialX = rect.left;
            initialY = rect.top;
            container.style.transition = 'none';
            document.addEventListener('mousemove', onMove);
            document.addEventListener('touchmove', onMove, { passive: false });
            document.addEventListener('mouseup', onEnd);
            document.addEventListener('touchend', onEnd);
        };

        container.addEventListener('mousedown', onStart);
        container.addEventListener('touchstart', onStart, { passive: false });
        container._hasMoved = () => hasMoved;

        return container;
    }

    // 显示错误状态(带登录链接和重试按钮)
    function showErrorWithLogin(container, onRetry) {
        container.textContent = '';
        container.style.color = '#333';
        container.appendChild(document.createTextNode('请登录 '));
        const link = document.createElement('a');
        link.href = 'https://credit.linux.do/';
        link.target = '_blank';
        link.style.cssText = 'color: #3b82f6; text-decoration: underline;';
        link.textContent = 'LDC';
        container.appendChild(link);
        if (onRetry) {
            const btn = document.createElement('span');
            btn.textContent = ' \u21BB';
            btn.title = '重新获取';
            btn.style.cssText = 'cursor: pointer; font-size: 16px; margin-left: 6px; color: #3b82f6; user-select: none;';
            btn.onclick = (e) => {
                e.stopPropagation();
                onRetry();
            };
            container.appendChild(btn);
        }
    }

    // 显示仅含错误提示的容器
    function showError(onRetry) {
        const container = createContainer();
        showErrorWithLogin(container, onRetry);
        document.body.appendChild(container);
        return () => {
            if (container.parentNode) container.parentNode.removeChild(container);
        };
    }

    // 创建 UI(统一排行榜页面和普通页面)
    function createUI(baseScore, onLeaderboard) {
        const container = createContainer();
        document.body.appendChild(container);

        let isLoading = false;
        let currentBaseScore = baseScore;

        // 更新显示
        const updateDisplay = (currentScore) => {
            const diff = currentScore - currentBaseScore;
            const sign = diff >= 0 ? '+' : '';
            const color = diff >= 0 ? '#22c55e' : '#ef4444';
            container.textContent = '';
            container.style.color = '#333';
            container.appendChild(document.createTextNode('今日: '));
            const span = document.createElement('span');
            span.style.cssText = `font-weight: bold; color: ${color};`;
            span.textContent = `${sign}${diff}`;
            container.appendChild(span);
        };

        // 获取当前积分
        const fetchCurrentScore = async () => {
            if (onLeaderboard) {
                const userEl = await waitForElement(document, SELECTORS.USER_SCORE);
                return parseScoreFromElement(userEl);
            }
            return await fetchScoreViaIframe();
        };

        // 显示带重试按钮的警告
        const showWarning = (msg) => {
            container.textContent = '';
            container.style.color = '#333';
            container.appendChild(document.createTextNode(msg));
            const btn = document.createElement('span');
            btn.textContent = ' \u21BB';
            btn.title = '重新获取';
            btn.style.cssText = 'cursor: pointer; font-size: 16px; margin-left: 6px; color: #3b82f6; user-select: none;';
            btn.onclick = (e) => { e.stopPropagation(); doFetch(); };
            container.appendChild(btn);
        };

        // 获取积分并更新 UI
        const doFetch = async () => {
            if (isLoading) return;
            isLoading = true;
            container.textContent = '获取中...';
            container.style.color = '#999';

            try {
                // 跨天后刷新基础积分
                const cachedBase = getCache(STORAGE_KEY);
                if (cachedBase === null) {
                    currentBaseScore = await fetchBaseScore();
                    setCache(STORAGE_KEY, currentBaseScore);
                    GM_setValue(CURRENT_SCORE_KEY, null);
                } else {
                    currentBaseScore = cachedBase;
                }
            } catch (e) {
                console.error('获取基础积分失败:', e);
                showErrorWithLogin(container, doFetch);
                isLoading = false;
                return;
            }

            try {
                const currentScore = await fetchCurrentScore();
                if (currentScore !== null) {
                    setCache(CURRENT_SCORE_KEY, currentScore);
                    updateDisplay(currentScore);
                } else {
                    showWarning('积分获取失败');
                }
            } catch (e) {
                console.error('获取当前积分失败:', e);
                showWarning('积分获取失败');
            }

            isLoading = false;
        };

        let clickTimeout = null;

        // 点击刷新(排除拖拽)
        container.onclick = (e) => {
            if (container._hasMoved()) return;
            if (e.target.tagName === 'A') return;
            if (clickTimeout) clearTimeout(clickTimeout);
            clickTimeout = setTimeout(() => {
                doFetch();
                clickTimeout = null;
            }, DELAYS.CLICK_DEBOUNCE);
        };

        // 双击清理缓存
        container.ondblclick = () => {
            if (clickTimeout) clearTimeout(clickTimeout);
            GM_setValue(STORAGE_KEY, null);
            GM_setValue(CURRENT_SCORE_KEY, null);
            GM_setValue(POSITION_KEY, null);
            location.reload();
        };

        // 初始显示:有缓存先展示
        const cachedCurrentScore = getCache(CURRENT_SCORE_KEY);
        if (cachedCurrentScore !== null) {
            updateDisplay(cachedCurrentScore);
        }

        // 排行榜页面或无缓存时自动获取一次
        if (onLeaderboard || cachedCurrentScore === null) {
            doFetch();
        }

        // 定时自动刷新
        const intervalId = setInterval(doFetch, AUTO_REFRESH_INTERVAL);

        return () => {
            if (clickTimeout) clearTimeout(clickTimeout);
            clearInterval(intervalId);
            if (container.parentNode) container.parentNode.removeChild(container);
        };
    }

    // 当前实例的清理函数
    let currentCleanup = null;
    let initCounter = 0;

    // 初始化(可重复调用,自动清理上一次实例)
    async function init() {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', init);
            return;
        }

        const currentInitId = ++initCounter;

        if (currentCleanup) {
            currentCleanup();
            currentCleanup = null;
        }

        // 获取基础积分(优先缓存)
        let baseScore = getCache(STORAGE_KEY);

        if (baseScore === null) {
            try {
                baseScore = await fetchBaseScore();
                setCache(STORAGE_KEY, baseScore);
            } catch (e) {
                console.error('获取积分失败:', e);
                if (currentInitId !== initCounter) return;
                currentCleanup = showError(init);
                return;
            }
        }

        if (currentInitId !== initCounter) return;
        currentCleanup = createUI(baseScore, isLeaderboardPage());
    }

    // 监听 SPA 导航
    let lastPath = location.pathname;
    const onNavigate = () => {
        if (location.pathname !== lastPath) {
            lastPath = location.pathname;
            init();
        }
    };

    window.addEventListener('popstate', onNavigate);

    const origPushState = history.pushState;
    const origReplaceState = history.replaceState;
    history.pushState = function(...args) {
        origPushState.apply(this, args);
        onNavigate();
    };
    history.replaceState = function(...args) {
        origReplaceState.apply(this, args);
        onNavigate();
    };

    init();
})();