Greasy Fork is available in English.
显示 linux.do 今日积分变化;如果 Safari 取不到积分,可先去 credit.linux.do 同步一次;每半小时自动刷新,双击清除缓存
// ==UserScript==
// @name Linux.do Credit Display
// @namespace http://tampermonkey.net/
// @version 3.1
// @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();
GM_setValue(CURRENT_SCORE_KEY, null);
location.reload();
};
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);
container.removeAttribute("title");
const tooltip = document.createElement("div");
tooltip.style.cssText = `
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
padding: 6px 12px;
background: #1f2937;
color: #f3f4f6;
font-size: 12px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 10000;
font-weight: normal;
`;
tooltip.innerHTML = `
<div style="margin-bottom: 4px;">当前: <span style="color: #60a5fa; font-weight: bold;">${currentScore}</span></div>
<div>基准: <span style="font-weight: bold;">${currentBaseScore}</span></div>
`;
const arrow = document.createElement("div");
arrow.style.cssText = `
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: #1f2937 transparent transparent transparent;
`;
tooltip.appendChild(arrow);
container.appendChild(tooltip);
container.onmouseenter = () => {
tooltip.style.opacity = "1";
tooltip.style.transform = "translateX(-50%) translateY(-2px)";
};
container.onmouseleave = () => {
tooltip.style.opacity = "0";
tooltip.style.transform = "translateX(-50%) translateY(0)";
};
};
// 获取当前积分
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();
GM_setValue(CURRENT_SCORE_KEY, null);
location.reload();
};
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();
})();