Greasy Fork is available in English.
显示 linux.do 今日积分变化,每半小时自动刷新,双击清除缓存
当前为
// ==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();
})();