// ==UserScript== // @name Linux.do Credit Display // @namespace http://tampermonkey.net/ // @version 2.7 // @description 显示 linux.do 今日积分变化,每半小时自动刷新,双击清除缓存 // @author You // @match https://linux.do/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect credit.linux.do // @downloadURL none // ==/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_POLL_INTERVAL = 100; const IFRAME_MAX_ATTEMPTS = 50; const IFRAME_TIMEOUT = 15000; const ELEMENT_WAIT_TIMEOUT = 5000; const AUTO_REFRESH_INTERVAL = 30 * 60 * 1000; // 30分钟 // 通用容器样式 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; `; // 获取今天的时间范围(使用本地时区) function getTodayRange() { const now = new Date(); // 1. 计算一周前 (当前日期 - 7) const start = new Date(now); start.setDate(now.getDate() - 7); // 2. 计算两天后 (当前日期 + 2) const end = new Date(now); end.setDate(now.getDate() + 2); // 格式化日期辅助函数 (YYYY-MM-DD) const 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}`; }; // 3. 获取本地时区偏移 (保持原逻辑不变) const offset = -now.getTimezoneOffset(); const sign = offset >= 0 ? '+' : '-'; const absOffset = Math.abs(offset); const hours = String(Math.floor(absOffset / 60)).padStart(2, '0'); const minutes = String(absOffset % 60).padStart(2, '0'); const tz = `${sign}${hours}:${minutes}`; return { startTime: `${formatDate(start)}T00:00:00${tz}`, endTime: `${formatDate(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(); // 1) 精确匹配:纯数字 / 带 k/m let match = t.replace(/,/g, '').match(/^(\d+(?:\.\d+)?)\s*([kKmM])?$/); if (match) { const value = parseFloat(match[1]); if (!isFinite(value)) return null; const unit = match[2]?.toLowerCase(); const multiplier = unit === 'k' ? 1000 : unit === 'm' ? 1000000 : 1; const result = Math.round(value * multiplier); return isNaN(result) ? null : result; } // 2) 文本中提取:如 "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 multiplier = unit === 'k' ? 1000 : unit === 'm' ? 1000000 : 1; const result = Math.round(value * multiplier); return isNaN(result) ? null : result; } // 3) 回退:提取第一个整数(支持逗号) match = t.match(/(\d[\d,]*)/); return match ? parseNumberText(match[1]) : null; } // 通用缓存读取(当天有效) function getCache(key) { const cache = GM_getValue(key, null); if (cache && cache.date === new Date().toDateString()) { return cache.score; } return null; } // 通用缓存写入 function setCache(key, score) { GM_setValue(key, { score, date: new Date().toDateString() }); } // 计算积分变化的显示信息 function getDiffDisplay(currentScore, baseScore) { const diff = currentScore - baseScore; const sign = diff >= 0 ? '+' : ''; const color = diff >= 0 ? '#22c55e' : '#ef4444'; return { diff, sign, color }; } // 获取今日基础积分 function fetchBaseScore() { return new Promise((resolve, reject) => { const { startTime, endTime } = getTodayRange(); GM_xmlhttpRequest({ method: 'POST', url: TRANSACTIONS_API, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ page: 1, page_size: PAGE_SIZE, startTime, endTime }), withCredentials: true, anonymous: false, onload: (res) => { try { if (res.status === 401 || res.status === 403) { reject(new Error('未登录或无权限')); return; } if (res.status < 200 || res.status >= 300) { reject(new Error(`请求失败: ${res.status}`)); return; } const data = JSON.parse(res.responseText); const orders = data.data?.orders || []; for (const item of orders) { if (item.remark?.includes('社区积分')) { const score = parseScore(item.remark); if (score !== null) { resolve(score); return; } } } reject(new Error('未找到积分记录')); } catch (e) { reject(e); } }, onerror: reject }); }); } // 等待元素出现 function waitForElement(selector, timeout = ELEMENT_WAIT_TIMEOUT) { return new Promise((resolve) => { const el = document.querySelector(selector); if (el) { resolve(el); return; } let resolved = false; const observer = new MutationObserver(() => { if (resolved) return; const el = document.querySelector(selector); if (el) { resolved = true; observer.disconnect(); resolve(el); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { if (!resolved) { resolved = true; observer.disconnect(); resolve(null); } }, timeout); }); } // 从排行榜页面 DOM 获取当前积分 function parseScoreFromElement(userEl) { if (!userEl) return null; const numberSpan = userEl.querySelector?.('.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 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 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'; }; 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 maxX = window.innerWidth - rect.width; const maxY = window.innerHeight - rect.height; const newX = Math.max(0, Math.min(maxX, initialX + dx)); const newY = Math.max(0, Math.min(maxY, 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 事件,结束时解绑,避免内存泄漏 const onStartWrapped = (e) => { onStart(e); document.addEventListener('mousemove', onMove); document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('mouseup', onEndWrapped); document.addEventListener('touchend', onEndWrapped); }; const onEndWrapped = () => { onEnd(); document.removeEventListener('mousemove', onMove); document.removeEventListener('touchmove', onMove); document.removeEventListener('mouseup', onEndWrapped); document.removeEventListener('touchend', onEndWrapped); }; container.addEventListener('mousedown', onStartWrapped); container.addEventListener('touchstart', onStartWrapped, { passive: false }); // 标记是否拖拽过,用于区分点击和拖拽 container._hasMoved = () => hasMoved; return container; } // 创建积分变化显示元素 function createDiffElement(currentScore, baseScore) { const { sign, diff, color } = getDiffDisplay(currentScore, baseScore); const span = document.createElement('span'); span.style.cssText = `font-weight: bold; color: ${color};`; span.textContent = `${sign}${diff}`; return span; } // 创建 UI(排行榜页面专用) function createLeaderboardUI(baseScore) { const container = createContainer(); container.style.cssText += 'cursor: pointer;'; document.body.appendChild(container); let isLoading = false; let currentBaseScore = baseScore; // 更新显示(与普通页面一致) const updateDisplay = (currentScore) => { container.textContent = ''; container.style.color = '#333'; container.appendChild(document.createTextNode('今日: ')); container.appendChild(createDiffElement(currentScore, currentBaseScore)); }; // 显示加载状态 const showLoading = () => { container.textContent = '获取中...'; container.style.color = '#999'; }; // 获取积分(从当前页面 DOM 读取,无需 iframe) const doFetch = async () => { if (isLoading) return; isLoading = true; showLoading(); 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; } // 直接从排行榜页面 DOM 读取积分 const userEl = await waitForElement('.user.-self .user__score'); const currentScore = parseScoreFromElement(userEl); if (currentScore !== null) { setCache(CURRENT_SCORE_KEY, currentScore); updateDisplay(currentScore); } else { container.textContent = '无法获取积分'; container.style.color = '#f59e0b'; } } catch (e) { console.error('获取积分失败:', e); showErrorWithLogin(container); } isLoading = false; }; // 点击刷新(排除拖拽) container.onclick = (e) => { if (container._hasMoved()) return; if (e.target.tagName === 'A') return; doFetch(); }; // 双击清理缓存 container.ondblclick = () => { 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); } // 排行榜页面可直接从 DOM 读取,无需 iframe,进入时自动更新一次 doFetch(); // 定时自动刷新 const intervalId = setInterval(doFetch, AUTO_REFRESH_INTERVAL); // 返回清理函数,供 SPA 导航时调用 return () => { clearInterval(intervalId); if (container.parentNode) container.parentNode.removeChild(container); }; } // 通过隐藏 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 cleanup = () => { if (iframe.parentNode) { document.body.removeChild(iframe); } }; const finish = (success, value) => { if (resolved) return; resolved = true; cleanup(); success ? resolve(value) : reject(value); }; iframe.onload = () => { let iframeDoc; try { iframeDoc = iframe.contentDocument || iframe.contentWindow.document; } catch (e) { finish(false, new Error('无法访问 iframe 内容(跨域限制)')); return; } let attempts = 0; const checkElement = () => { if (resolved) return; // 优先获取 title 属性(移动端显示 2.1k,但 title 有完整数值) const userEl = iframeDoc.querySelector('.user.-self .user__score'); let score = null; if (userEl) { const numberSpan = userEl.querySelector('.number[title]'); if (numberSpan) { score = parseNumberText(numberSpan.getAttribute('title')); } if (score === null) { score = parseCompactNumberText(userEl.textContent); } } if (score !== null) { finish(true, score); } else if (attempts < IFRAME_MAX_ATTEMPTS) { attempts++; setTimeout(checkElement, IFRAME_POLL_INTERVAL); } else { finish(false, new Error('无法找到积分元素')); } }; setTimeout(checkElement, 500); }; iframe.onerror = () => { finish(false, new Error('iframe 加载失败')); }; setTimeout(() => { finish(false, new Error('获取积分超时')); }, IFRAME_TIMEOUT); document.body.appendChild(iframe); }); } // 创建 UI(普通页面) function createUI(baseScore) { const container = createContainer(); container.style.cssText += 'cursor: pointer;'; let isLoading = false; let currentBaseScore = baseScore; // 更新显示 const updateDisplay = (currentScore) => { container.textContent = ''; container.appendChild(document.createTextNode('今日: ')); container.appendChild(createDiffElement(currentScore, currentBaseScore)); }; // 显示加载状态 const showLoading = () => { container.textContent = '获取中...'; container.style.color = '#999'; }; // 显示错误 const showError = (msg) => { container.textContent = msg; container.style.color = '#ef4444'; }; // 恢复正常样式 const resetStyle = () => { container.style.color = '#333'; }; // 获取积分 const doFetch = async () => { if (isLoading) return; isLoading = true; showLoading(); 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; } const currentScore = await fetchScoreViaIframe(); setCache(CURRENT_SCORE_KEY, currentScore); resetStyle(); updateDisplay(currentScore); } catch (e) { console.error('获取积分失败:', e); showErrorWithLogin(container); } isLoading = false; }; document.body.appendChild(container); // 点击刷新(排除拖拽) container.onclick = (e) => { if (container._hasMoved()) return; if (e.target.tagName === 'A') return; doFetch(); }; // 双击清理缓存 container.ondblclick = () => { 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); } else { container.textContent = '点击获取今日积分'; } // 定时自动刷新 const intervalId = setInterval(doFetch, AUTO_REFRESH_INTERVAL); // 返回清理函数,供 SPA 导航时调用 return () => { clearInterval(intervalId); if (container.parentNode) container.parentNode.removeChild(container); }; } // 显示错误状态(带登录链接) function showErrorWithLogin(container) { 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 = 'LINUX DO Credit'; container.appendChild(link); } // 显示错误状态 function showError() { const container = createContainer(); showErrorWithLogin(container); document.body.appendChild(container); return container; } // 当前实例的清理函数 let currentCleanup = null; // 初始化(可重复调用,自动清理上一次实例) async function init() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); return; } // 清理上一次的 UI 和定时器 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); showError(); return; } } // 根据页面类型显示不同 UI if (isLeaderboardPage()) { currentCleanup = createLeaderboardUI(baseScore); } else { currentCleanup = createUI(baseScore); } } // 监听 SPA 导航(linux.do 是 Ember.js 应用,URL 变化不刷新页面) let lastPath = location.pathname; const onNavigate = () => { if (location.pathname !== lastPath) { lastPath = location.pathname; init(); } }; // popstate 处理浏览器前进/后退 window.addEventListener('popstate', onNavigate); // 劫持 pushState/replaceState 处理 SPA 路由跳转 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(); })();