// ==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 // @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_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 = `
当前: ${currentScore}
基准: ${currentBaseScore}
`; 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(); })();