显示 linux.do 今日积分变化;如果 Safari 取不到积分,可先去 credit.linux.do 同步一次;每半小时自动刷新,双击清除缓存
// ==UserScript==
// @name Linux.do Credit Display
// @namespace http://tampermonkey.net/
// @version 3.0
// @description 显示 linux.do 今日积分变化;如果 Safari 取不到积分,可先去 credit.linux.do 同步一次;每半小时自动刷新,双击清除缓存
// @author qppq54s
// @match https://linux.do/*
// @match https://credit.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 createAuthRequiredError(code = 'AUTH_REQUIRED', message = '未登录或无权限') {
const authError = new Error(message);
authError.code = code;
return authError;
}
// 从响应中解析积分数据(公用逻辑)
function parseBaseScoreFromResponse(res) {
if (res.status === 401 || res.status === 403) {
throw createAuthRequiredError('CREDIT_AUTH_REQUIRED');
}
if (res.status < 200 || res.status >= 300) {
throw 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 score;
}
}
throw new Error('未找到积分记录');
}
// 构建 API 请求体
function buildRequestBody() {
const { startTime, endTime } = getTodayRange();
return JSON.stringify({ page: 1, page_size: PAGE_SIZE, startTime, endTime, types: ["community"] });
}
// 方式一:通过 GM_xmlhttpRequest + withCredentials 获取积分(桌面端正常工作)
function fetchBaseScoreViaGM() {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: TRANSACTIONS_API,
headers: {
'Content-Type': 'application/json',
'Origin': 'https://credit.linux.do',
'Referer': 'https://credit.linux.do/'
},
data: buildRequestBody(),
withCredentials: true,
anonymous: false,
onload: (res) => {
try {
resolve(parseBaseScoreFromResponse(res));
} catch (e) {
reject(e);
}
},
onerror: reject
});
});
}
// 方式二:同域 fetch(仅在 credit.linux.do 页面上有效,完全绕过 ITP)
function fetchBaseScoreDirectly() {
return fetch(TRANSACTIONS_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: buildRequestBody(),
credentials: 'same-origin'
}).then(async (response) => {
const text = await response.text();
return parseBaseScoreFromResponse({ status: response.status, responseText: text });
});
}
// 判断是否在 credit.linux.do
function isCreditPage() {
return location.hostname === 'credit.linux.do';
}
// Safari / iOS WebKit 更容易遇到跨站 cookie 限制
function hasWebKitCrossSiteCookieLimit() {
const ua = navigator.userAgent;
const isIOSWebKit = /iPhone|iPad|iPod/i.test(ua);
const isSafari = /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS|Android/i.test(ua);
return isIOSWebKit || isSafari;
}
// 判断页面是否已经落到登录/认证状态
function isAuthRequiredPage(doc) {
if (!doc) return false;
try {
const pathname = doc.location?.pathname || '';
if (/\/(login|session|auth)\b/i.test(pathname)) {
return true;
}
} catch (e) {}
return !!(
doc.querySelector('input[type="password"]') ||
doc.querySelector('form[action*="/session"]') ||
doc.querySelector('a[href*="/login"]') ||
doc.querySelector('a[href*="/session"]')
);
}
// 在 credit.linux.do 页面上显示提示 toast
function showCreditPageToast(msg, isError) {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed; bottom: 20px; right: 20px;
background: ${isError ? '#fef2f2' : '#f0fdf4'};
color: ${isError ? '#991b1b' : '#166534'};
border: 1px solid ${isError ? '#fecaca' : '#bbf7d0'};
padding: 10px 16px; border-radius: 8px; font-size: 13px;
z-index: 9999; box-shadow: 0 2px 12px rgba(0,0,0,0.1);
transition: opacity 0.3s; max-width: 280px;
`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// 在 credit.linux.do 页面上:同域获取基础积分并缓存
function initOnCreditPage() {
fetchBaseScoreDirectly().then(score => {
setCache(STORAGE_KEY, score);
GM_setValue(CURRENT_SCORE_KEY, null);
console.log('[LDC] 在 credit.linux.do 上缓存了基础积分:', score);
showCreditPageToast(`✅ 今日积分起点已同步 (${score}),回到 linux.do 刷新后就能看`, false);
}).catch(e => {
console.warn('[LDC] credit.linux.do 上获取积分失败:', e);
showCreditPageToast('❌ 同步失败,请确认已经登录后再试', true);
});
}
// 获取今日基础积分
function fetchBaseScore() {
return fetchBaseScoreViaGM()
.catch(e => {
if (e.code === 'CREDIT_AUTH_REQUIRED' && !isCreditPage() && hasWebKitCrossSiteCookieLimit()) {
const authError = new Error('未登录或无权限(请先去 credit.linux.do 同步一次积分)');
authError.code = 'CROSS_SITE_AUTH_REQUIRED';
throw authError;
}
throw e;
});
}
// 等待元素出现(在指定文档中)
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);
if (!userEl && isAuthRequiredPage(iframeDoc)) {
return finish(false, createAuthRequiredError('LINUX_DO_AUTH_REQUIRED'));
}
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 isMobile() {
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
}
// 创建 LDC 链接元素
function createLDCLink() {
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';
return link;
}
function createLinuxDoLink() {
const link = document.createElement('a');
link.href = 'https://linux.do/login';
link.style.cssText = 'color: #3b82f6; text-decoration: underline;';
link.textContent = 'linux.do';
return link;
}
// 显示错误状态(带登录链接和重试按钮)
function showErrorWithLogin(container, onRetry, error = null) {
container.textContent = '';
container.style.color = '#333';
if (error?.code === 'CROSS_SITE_AUTH_REQUIRED') {
container.appendChild(document.createTextNode('请先去 '));
container.appendChild(createLDCLink());
container.appendChild(document.createTextNode(' 同步一次积分后再刷新'));
} else if (error?.code === 'CREDIT_AUTH_REQUIRED') {
container.appendChild(document.createTextNode(isMobile() ? '请先去 ' : '请登录 '));
container.appendChild(createLDCLink());
if (isMobile()) {
container.appendChild(document.createTextNode(' 登录一下,再回来刷新'));
}
} else if (error?.code === 'LINUX_DO_AUTH_REQUIRED') {
container.appendChild(document.createTextNode(isMobile() ? '请先登录 ' : '请登录 '));
container.appendChild(createLinuxDoLink());
if (isMobile()) {
container.appendChild(document.createTextNode(',再回来刷新'));
}
} else {
container.appendChild(document.createTextNode('获取失败,请重试'));
}
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, error = null) {
const container = createContainer();
showErrorWithLogin(container, onRetry, error);
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);
if (!userEl && isAuthRequiredPage(document)) {
throw createAuthRequiredError('LINUX_DO_AUTH_REQUIRED');
}
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, e);
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);
if (e?.code === 'LINUX_DO_AUTH_REQUIRED' || e?.code === 'CROSS_SITE_AUTH_REQUIRED' || e?.code === 'CREDIT_AUTH_REQUIRED') {
showErrorWithLogin(container, doFetch, e);
} else {
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;
}
// 如果在 credit.linux.do 页面,仅缓存积分数据,不显示 UI
if (isCreditPage()) {
initOnCreditPage();
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, e);
return;
}
}
if (currentInitId !== initCounter) return;
currentCleanup = createUI(baseScore, isLeaderboardPage());
}
// 监听 SPA 导航(仅 linux.do)
if (!isCreditPage()) {
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();
})();