// ==UserScript== // @name Linux.do Credit display // @namespace https://tampermonkey.net/ // @version 0.3.6 // @description 每天最多完整刷新一次 credit.linux.do 排行榜并缓存;支持断点续抓(指定页继续)、翻页、失败重试、429 等30s重试;在 linux.do 用户名旁显示 available_balance;带可折叠控制面板与缓存排行查看。 // @author popy // @match https://linux.do/* // @match https://credit.linux.do/* // @run-at document-end // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @connect credit.linux.do // @connect github.com // @connect raw.githubusercontent.com // @updateURL https://raw.githubusercontent.com/Poppypy/Linux.do-Credit-display-/main/Linux.do-Credit-display.user.js // @downloadURL https://raw.githubusercontent.com/Poppypy/Linux.do-Credit-display-/main/Linux.do-Credit-display.user.js // @icon https://linux.do/uploads/default/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_180x180.png // ==/UserScript== (function () { 'use strict'; /*********************** * 0) 基础常量/工具 ***********************/ const ORIGIN_LINUX = 'https://linux.do'; const ORIGIN_CREDIT = 'https://credit.linux.do'; const API_LEADERBOARD = `${ORIGIN_CREDIT}/api/v1/leaderboard`; const STORE = { meta: 'ldc_lb_meta_v2', list: 'ldc_lb_list_v2', idxById: 'ldc_lb_idx_id_v2', // { "9797": 8, ... } => index in list idxByName: 'ldc_lb_idx_name_v2', // { "zh-heng": 8, ... } => index in list settings: 'ldc_lb_settings_v2', panel: 'ldc_lb_panel_v2', }; const DEFAULT_SETTINGS = { pageSize: 50, delayMs: 1500, // 每页基础间隔 jitterMs: 900, // 抖动 retryCount: 3, // 普通错误重试次数 retryBaseDelayMs: 1500, // 普通错误退避基数 saveEveryPages: 5, // 每抓N页落盘一次 injectEnabled: true, // ✅429:限速等待 retry429DelayMs: 30000, // 30 秒 max429RetriesPerPage: 50, // 单页最多等 50 次(防止无穷等待) }; const TAB_ID = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; const LOCK_KEY = 'ldc_lb_fetch_lock_v2'; const LOCK_TTL = 60 * 1000; // 1 分钟 const LOCK_HEARTBEAT = 15 * 1000; function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function dayKeyLocal(d = new Date()) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; } function formatBalance(val) { const n = Number(val); if (!Number.isFinite(n)) return String(val ?? ''); const s = n.toFixed(2) .replace(/\.00$/, '') .replace(/(\.\d)0$/, '$1'); return s; } function safeJsonParse(text) { try { return JSON.parse(text); } catch { return null; } } function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } /******************************************************* * 1) credit.linux.do:桥接端(同域 fetch 自动带 cookie) *******************************************************/ if (location.origin === ORIGIN_CREDIT) { window.addEventListener('message', async (e) => { if (e.origin !== ORIGIN_LINUX) return; if (!e.data || e.data.type !== 'ldc-lb-request') return; const { requestId, url, method = 'GET' } = e.data; try { const res = await fetch(url, { method, credentials: 'include', headers: { 'Accept': 'application/json' } }); let data = null; try { data = await res.json(); } catch { const txt = await res.text().catch(() => ''); data = { _error: 'JSON_PARSE_FAILED', _raw: txt.slice(0, 300) }; } window.parent?.postMessage({ type: 'ldc-lb-response', requestId, status: res.status, data }, ORIGIN_LINUX); } catch (err) { window.parent?.postMessage({ type: 'ldc-lb-response', requestId, status: 0, data: { _error: 'NETWORK_ERROR', message: String(err) } }, ORIGIN_LINUX); } }); return; } if (location.origin !== ORIGIN_LINUX) return; /*********************** * 2) 存储读取 ***********************/ function loadSettings() { const s = GM_getValue(STORE.settings, null); return { ...DEFAULT_SETTINGS, ...(s && typeof s === 'object' ? s : {}) }; } function saveSettings(s) { GM_setValue(STORE.settings, s); } function loadMeta() { const m = GM_getValue(STORE.meta, null); return (m && typeof m === 'object') ? m : { lastFullFetchDay: null, lastFullFetchTs: 0, total: 0, pages: 0, pageSize: DEFAULT_SETTINGS.pageSize, fetchedPages: 0, // 已成功抓取到的最大页 fetchedItems: 0, // 已写入条数 partial: false, // 是否未完成 lastError: '', }; } function saveMeta(m) { GM_setValue(STORE.meta, m); } function loadCache() { const list = GM_getValue(STORE.list, null); const idxById = GM_getValue(STORE.idxById, null); const idxByName = GM_getValue(STORE.idxByName, null); return { list: Array.isArray(list) ? list : [], idxById: (idxById && typeof idxById === 'object') ? idxById : {}, idxByName: (idxByName && typeof idxByName === 'object') ? idxByName : {}, }; } function saveCache({ list, idxById, idxByName }) { GM_setValue(STORE.list, list); GM_setValue(STORE.idxById, idxById); GM_setValue(STORE.idxByName, idxByName); } let settings = loadSettings(); let meta = loadMeta(); let cache = loadCache(); /*********************** * 3) 互斥锁(多标签页互斥抓取) ***********************/ function readLock() { const raw = localStorage.getItem(LOCK_KEY); const j = raw ? safeJsonParse(raw) : null; return j && typeof j === 'object' ? j : null; } function writeLock(obj) { try { localStorage.setItem(LOCK_KEY, JSON.stringify(obj)); } catch {} } function acquireLock() { const now = Date.now(); const lock = readLock(); if (lock && lock.tabId && lock.ts && (now - lock.ts) < LOCK_TTL && lock.tabId !== TAB_ID) { return false; } writeLock({ tabId: TAB_ID, ts: now }); return true; } function heartbeatLock() { const lock = readLock(); if (!lock || lock.tabId !== TAB_ID) return; writeLock({ tabId: TAB_ID, ts: Date.now() }); } function releaseLock() { const lock = readLock(); if (lock && lock.tabId === TAB_ID) { try { localStorage.removeItem(LOCK_KEY); } catch {} } } /*********************** * 4) Bridge:linux.do -> (iframe) credit.linux.do ***********************/ let bridgeFrame = null; let bridgeReady = null; let bridgeReqId = 0; const pending = new Map(); function initBridge() { if (bridgeFrame) return; window.addEventListener('message', (e) => { if (e.origin !== ORIGIN_CREDIT) return; if (!e.data || e.data.type !== 'ldc-lb-response') return; const { requestId, status, data } = e.data; const resolver = pending.get(requestId); if (resolver) { pending.delete(requestId); resolver({ status, data }); } }); bridgeFrame = document.createElement('iframe'); bridgeFrame.src = `${ORIGIN_CREDIT}/home`; bridgeFrame.style.cssText = 'width:0;height:0;opacity:0;position:absolute;left:-9999px;top:-9999px;border:0;pointer-events:none;'; document.body.appendChild(bridgeFrame); bridgeReady = new Promise((resolve) => { const t = setTimeout(resolve, 6000); bridgeFrame.onload = () => { clearTimeout(t); setTimeout(resolve, 250); }; }); } async function bridgeRequest(url, method = 'GET', timeoutMs = 15000) { initBridge(); await bridgeReady; if (!bridgeFrame || !bridgeFrame.contentWindow) { return { status: 0, data: { _error: 'BRIDGE_NOT_READY' } }; } return new Promise((resolve) => { const id = ++bridgeReqId; const timer = setTimeout(() => { pending.delete(id); resolve({ status: 0, data: { _error: 'TIMEOUT' } }); }, timeoutMs); pending.set(id, (resp) => { clearTimeout(timer); resolve(resp); }); try { bridgeFrame.contentWindow.postMessage( { type: 'ldc-lb-request', requestId: id, url, method }, ORIGIN_CREDIT ); } catch (e) { clearTimeout(timer); pending.delete(id); resolve({ status: 0, data: { _error: 'POSTMESSAGE_FAILED', message: String(e) } }); } }); } async function gmRequest(url, timeoutMs = 15000) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url, withCredentials: true, timeout: timeoutMs, headers: { 'Accept': 'application/json', 'Referer': `${ORIGIN_CREDIT}/leaderboard` }, onload: (r) => { const j = safeJsonParse(r.responseText); resolve({ status: r.status, data: j || { _error: 'JSON_PARSE_FAILED', _raw: (r.responseText || '').slice(0, 300) } }); }, onerror: () => resolve({ status: 0, data: { _error: 'NETWORK_ERROR' } }), ontimeout: () => resolve({ status: 0, data: { _error: 'TIMEOUT' } }), }); }); } async function apiGet(url) { const r1 = await bridgeRequest(url, 'GET'); if (r1 && r1.status === 200 && r1.data && !r1.data._error) return r1; return await gmRequest(url); } function unwrapLeaderboardResponse(resp) { if (!resp || typeof resp !== 'object') return null; const d = resp.data; if (d && Array.isArray(d.items)) return d; if (Array.isArray(resp.items)) return resp; return null; } /*********************** * 5) 控制面板 UI ***********************/ GM_addStyle(` .ldc-lb-panel{position:fixed;right:14px;bottom:14px;z-index:999999;background:var(--secondary, #111);color:var(--primary, #fff); border:1px solid rgba(255,255,255,.12);border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.35);width:360px;max-height:72vh;overflow:hidden;font-size:12px} .ldc-lb-panel.light{background:#fff;color:#111;border:1px solid rgba(0,0,0,.12)} .ldc-lb-panel.collapsed{width:40px;height:40px;min-height:40px;border-radius:50%;padding:0;overflow:hidden} .ldc-lb-panel.collapsed .ldc-lb-header{padding:0;border:none;height:100%;justify-content:center} .ldc-lb-panel.collapsed .ldc-lb-title,.ldc-lb-panel.collapsed .ldc-lb-actions button:not([data-act="collapse"]),.ldc-lb-panel.collapsed .ldc-lb-body{display:none} .ldc-lb-panel.collapsed .ldc-btn[data-act="collapse"]{width:100%;height:100%;border:none;background:transparent;font-size:10px} .ldc-lb-panel.ldc-dragging{opacity:0.9;cursor:grabbing} .ldc-lb-header{display:flex;align-items:center;justify-content:space-between;padding:10px 10px;border-bottom:1px solid rgba(255,255,255,.10)} .ldc-lb-panel.light .ldc-lb-header{border-bottom:1px solid rgba(0,0,0,.10)} .ldc-lb-header{cursor:grab} .ldc-lb-title{font-weight:700} .ldc-lb-actions{display:flex;gap:6px;align-items:center} .ldc-btn{cursor:pointer;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.08);color:inherit;border-radius:8px;padding:6px 8px;font-size:12px} .ldc-lb-panel.light .ldc-btn{border:1px solid rgba(0,0,0,.15);background:rgba(0,0,0,.05)} .ldc-btn:disabled{opacity:.5;cursor:not-allowed} .ldc-lb-body{padding:10px;overflow:auto;max-height:calc(72vh - 46px)} .ldc-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:6px 0} .ldc-kv{opacity:.9} .ldc-muted{opacity:.65} .ldc-progress{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace} .ldc-hr{height:1px;background:rgba(255,255,255,.12);margin:10px 0} .ldc-lb-panel.light .ldc-hr{background:rgba(0,0,0,.12)} .ldc-input{box-sizing:border-box;border-radius:8px;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.06);color:inherit;padding:7px 9px;font-size:12px} .ldc-lb-panel.light .ldc-input{border:1px solid rgba(0,0,0,.15);background:rgba(0,0,0,.04)} .ldc-list{margin-top:8px;border:1px solid rgba(255,255,255,.12);border-radius:10px;overflow:hidden} .ldc-lb-panel.light .ldc-list{border:1px solid rgba(0,0,0,.12)} .ldc-item{display:flex;gap:8px;align-items:center;justify-content:space-between;padding:7px 9px;border-top:1px solid rgba(255,255,255,.08)} .ldc-item:first-child{border-top:none} .ldc-rank{min-width:54px;opacity:.8} .ldc-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ldc-val{font-weight:700} .ldc-toast{position:fixed;right:14px;bottom:92px;z-index:999999;background:rgba(0,0,0,.78);color:#fff;padding:10px 12px;border-radius:10px;max-width:420px;font-size:12px;box-shadow:0 6px 18px rgba(0,0,0,.35)} .ldc-badge{display:inline-flex;align-items:center;gap:4px;margin-left:6px;padding:2px 6px;border-radius:999px;font-size:11px;line-height:1.4;white-space:nowrap; border:1px solid rgba(0,0,0,.08);background:rgba(255,215,0,.18)} .ldc-badge .ldc-coin{opacity:.85} `); let panelEl = null; let bodyEl = null; let statusEl = null; let progressEl = null; let searchInputEl = null; let searchResultEl = null; let injectToggleEl = null; let rankListEl = null; let rankPageEl = null; let rankPrevBtn = null; let rankNextBtn = null; let startPageEl = null; let running = false; let stopFlag = false; let lockTimer = null; let dragging = false; let dragOffset = { x: 0, y: 0 }; let suppressCollapseClick = false; const panelState = (GM_getValue(STORE.panel, null) && typeof GM_getValue(STORE.panel, null) === 'object') ? GM_getValue(STORE.panel, null) : { collapsed: false, light: false, rankPage: 1, left: null, top: null }; function savePanelState() { GM_setValue(STORE.panel, panelState); } function toast(msg, ms = 3000) { const t = document.createElement('div'); t.className = 'ldc-toast'; t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), ms); } function humanTime(ts) { if (!ts) return '—'; try { return new Date(ts).toLocaleString('zh-CN', { hour12: false }); } catch { return '—'; } } function canFetchToday() { return meta.lastFullFetchDay !== dayKeyLocal(); } function createPanel() { panelEl = document.createElement('div'); panelEl.className = 'ldc-lb-panel' + (panelState.light ? ' light' : '') + (panelState.collapsed ? ' collapsed' : ''); if (Number.isFinite(panelState.left) && Number.isFinite(panelState.top)) { panelEl.style.left = `${panelState.left}px`; panelEl.style.top = `${panelState.top}px`; panelEl.style.right = 'auto'; panelEl.style.bottom = 'auto'; } panelEl.innerHTML = `
💳 Credit 排行榜缓存
从第 页开始
缓存条数:0 |最后更新:
今日状态: |注入:
就绪
🔎 搜索用户
🏆 缓存排行(分页查看)
⚙️ 抓取参数
每页间隔(ms):
抖动(ms):
重试次数:
落盘频率(页):
429等待(ms):
429上限/页:
(太快可能触发 CF/429,建议间隔 ≥ 1200ms)
`; document.body.appendChild(panelEl); bodyEl = panelEl.querySelector('.ldc-lb-body'); statusEl = panelEl.querySelector('[data-k="status"]'); progressEl = panelEl.querySelector('[data-k="progress"]'); searchInputEl = panelEl.querySelector('[data-k="search"]'); searchResultEl = panelEl.querySelector('[data-k="searchResult"]'); startPageEl = panelEl.querySelector('input[data-k="startPage"]'); injectToggleEl = panelEl.querySelector('input[data-k="inject"]'); injectToggleEl.checked = !!settings.injectEnabled; rankListEl = panelEl.querySelector('[data-k="rankList"]'); rankPageEl = panelEl.querySelector('[data-k="rankPageText"]'); rankPrevBtn = panelEl.querySelector('[data-k="rankPrev"]'); rankNextBtn = panelEl.querySelector('[data-k="rankNext"]'); const headerEl = panelEl.querySelector('.ldc-lb-header'); const onDragMove = (e) => { if (!dragging) return; suppressCollapseClick = true; const left = e.clientX - dragOffset.x; const top = e.clientY - dragOffset.y; panelEl.style.left = `${left}px`; panelEl.style.top = `${top}px`; panelEl.style.right = 'auto'; panelEl.style.bottom = 'auto'; panelState.left = left; panelState.top = top; }; const onDragEnd = () => { if (!dragging) return; dragging = false; panelEl.classList.remove('ldc-dragging'); document.removeEventListener('mousemove', onDragMove); document.removeEventListener('mouseup', onDragEnd); savePanelState(); // 防止拖动后立即触发点击导致自动展开/折叠 setTimeout(() => { suppressCollapseClick = false; }, 120); }; const onDragStart = (e) => { if (e.button !== 0) return; dragging = true; suppressCollapseClick = false; const rect = panelEl.getBoundingClientRect(); dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; panelEl.classList.add('ldc-dragging'); document.addEventListener('mousemove', onDragMove); document.addEventListener('mouseup', onDragEnd); e.preventDefault(); }; headerEl?.addEventListener('mousedown', onDragStart); panelEl.addEventListener('mousedown', (e) => { if (panelState.collapsed && e.button === 0) onDragStart(e); }); // 绑定事件:按钮 panelEl.addEventListener('click', (e) => { const btn = e.target.closest('button'); if (!btn) return; const act = btn.getAttribute('data-act'); if (act === 'collapse') { if (suppressCollapseClick) return; panelState.collapsed = !panelState.collapsed; savePanelState(); panelEl.classList.toggle('collapsed', panelState.collapsed); btn.textContent = panelState.collapsed ? '➕' : '➖'; bodyEl.style.display = panelState.collapsed ? 'none' : ''; return; } if (act === 'theme') { panelState.light = !panelState.light; savePanelState(); panelEl.classList.toggle('light', panelState.light); return; } if (act === 'fetch') { startFetch({ force: false, resume: false, startPage: 1 }); return; } if (act === 'force') { if (confirm('确定要强制刷新吗?这会忽略“每天一次”的限制,并重新抓取全部页面(可能触发限速)。')) { startFetch({ force: true, resume: false, startPage: 1 }); } return; } if (act === 'resume') { const sp = Number(startPageEl?.value); startFetch({ force: false, resume: true, startPage: sp }); return; } if (act === 'stop') { stopFetch(); return; } if (act === 'clear') { if (confirm('确定清空缓存吗?')) { cache = { list: [], idxById: {}, idxByName: {} }; meta = { lastFullFetchDay: null, lastFullFetchTs: 0, total: 0, pages: 0, pageSize: settings.pageSize, fetchedPages: 0, fetchedItems: 0, partial: false, lastError: '' }; saveCache(cache); saveMeta(meta); toast('已清空缓存'); renderPanel(); if (settings.injectEnabled) scheduleDecorate(document); } return; } if (act === 'saveSettings') { const d = Number(panelEl.querySelector('input[data-k="delay"]').value); const j = Number(panelEl.querySelector('input[data-k="jitter"]').value); const r = Number(panelEl.querySelector('input[data-k="retry"]').value); const se = Number(panelEl.querySelector('input[data-k="saveEvery"]').value); const r429 = Number(panelEl.querySelector('input[data-k="retry429"]').value); const m429 = Number(panelEl.querySelector('input[data-k="max429"]').value); settings.delayMs = Number.isFinite(d) ? Math.max(0, Math.floor(d)) : settings.delayMs; settings.jitterMs = Number.isFinite(j) ? Math.max(0, Math.floor(j)) : settings.jitterMs; settings.retryCount = Number.isFinite(r) ? Math.max(0, Math.floor(r)) : settings.retryCount; settings.saveEveryPages = Number.isFinite(se) ? Math.max(1, Math.floor(se)) : settings.saveEveryPages; settings.retry429DelayMs = Number.isFinite(r429) ? Math.max(1000, Math.floor(r429)) : settings.retry429DelayMs; settings.max429RetriesPerPage = Number.isFinite(m429) ? Math.max(1, Math.floor(m429)) : settings.max429RetriesPerPage; saveSettings(settings); toast('设置已保存'); renderPanel(); return; } if (act === 'support5') { window.open('https://credit.linux.do/paying/online?token=36ca0bae4257df6ae6208f7051ad7094f50cf1f993aee13d9389b7a66326899e', '_blank', 'noopener'); return; } if (act === 'support10') { window.open('https://credit.linux.do/paying/online?token=6861b422541da92d3d2d150ec0fdbae50f934dacede2eabfb3550b4468c93c00', '_blank', 'noopener'); return; } if (act === 'support20') { window.open('https://credit.linux.do/paying/online?token=1b375a20d1e6bcdcb24efbe1e8bce2c762d9ee99b207fb51f167916055f01d9f', '_blank', 'noopener'); return; } }); // 排行翻页 rankPrevBtn.addEventListener('click', () => { panelState.rankPage = Math.max(1, (panelState.rankPage || 1) - 1); savePanelState(); renderRankList(); }); rankNextBtn.addEventListener('click', () => { panelState.rankPage = (panelState.rankPage || 1) + 1; savePanelState(); renderRankList(); }); // 注入开关 injectToggleEl.addEventListener('change', () => { settings.injectEnabled = !!injectToggleEl.checked; saveSettings(settings); toast(settings.injectEnabled ? '已开启:页面显示余额' : '已关闭:页面显示余额'); if (settings.injectEnabled) scheduleDecorate(document); else removeAllBadges(); }); // 搜索 searchInputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(searchInputEl.value); }); searchInputEl.addEventListener('input', () => { if (!searchInputEl.value.trim()) searchResultEl.textContent = ''; }); } function renderPanel() { if (!panelEl) return; panelEl.querySelector('[data-k="count"]').textContent = String(cache.list.length || 0); panelEl.querySelector('[data-k="time"]').textContent = meta.lastFullFetchTs ? humanTime(meta.lastFullFetchTs) : '—'; panelEl.querySelector('[data-k="today"]').textContent = canFetchToday() ? '可刷新' : (meta.partial ? '今日已刷新(未完成,可续传)' : '今日已刷新'); // 按钮状态 const btnFetch = panelEl.querySelector('button[data-act="fetch"]'); const btnForce = panelEl.querySelector('button[data-act="force"]'); const btnResume = panelEl.querySelector('button[data-act="resume"]'); const btnStop = panelEl.querySelector('button[data-act="stop"]'); btnFetch.disabled = running || (!canFetchToday()); // “开始获取”仍遵守每天一次 btnForce.disabled = running; btnResume.disabled = running || !(meta.partial && cache.list.length > 0); btnStop.disabled = !running; // 参数回显 panelEl.querySelector('input[data-k="delay"]').value = String(settings.delayMs); panelEl.querySelector('input[data-k="jitter"]').value = String(settings.jitterMs); panelEl.querySelector('input[data-k="retry"]').value = String(settings.retryCount); panelEl.querySelector('input[data-k="saveEvery"]').value = String(settings.saveEveryPages); panelEl.querySelector('input[data-k="retry429"]').value = String(settings.retry429DelayMs); panelEl.querySelector('input[data-k="max429"]').value = String(settings.max429RetriesPerPage); // 续传默认页:未运行时自动填“下一页” if (startPageEl && !running) { startPageEl.value = String((meta.fetchedPages || 0) + 1); } progressEl.textContent = running ? `抓取中:页 ${meta.fetchedPages}/${meta.pages || '?'} | 已抓 ${meta.fetchedItems}/${meta.total || '?'}` : `就绪|总缓存 ${cache.list.length} 条`; statusEl.textContent = meta.lastError ? `最近错误:${meta.lastError}` : ''; renderRankList(); } function renderRankList() { if (!rankListEl) return; const pageSize = 50; const total = cache.list.length; const totalPages = Math.max(1, Math.ceil(total / pageSize)); const p = Math.min(Math.max(1, panelState.rankPage || 1), totalPages); panelState.rankPage = p; savePanelState(); rankPageEl.textContent = total ? `第 ${p}/${totalPages} 页(每页 ${pageSize})` : '暂无缓存(先点击“开始获取”)'; const start = (p - 1) * pageSize; const end = Math.min(total, start + pageSize); const slice = cache.list.slice(start, end); rankListEl.innerHTML = slice.map((it, i) => { const rank = start + i + 1; const id = it[0]; const name = it[1]; const bal = it[2]; const url = `/u/${encodeURIComponent(name)}`; return `
#${rank}
${escapeHtml(name)}
💰${escapeHtml(bal)}
`; }).join(''); } function doSearch(q) { q = (q || '').trim(); if (!q) return; const lower = q.toLowerCase(); let idx = null; if (/^\d+$/.test(q)) idx = cache.idxById[q] ?? null; if (idx == null) idx = cache.idxByName[lower] ?? null; if (idx == null || !cache.list[idx]) { searchResultEl.textContent = '未命中(缓存里找不到这个用户)'; return; } const [id, name, bal] = cache.list[idx]; const rank = idx + 1; searchResultEl.innerHTML = `命中:#${rank}${escapeHtml(name)}(user_id=${escapeHtml(id)})| 💰${escapeHtml(bal)}`; } /*********************** * 6) 抓取逻辑(翻页 + 重试 + 429等待 + 续传 + 分段落盘) ***********************/ async function fetchPage(page) { const url = `${API_LEADERBOARD}?page=${page}&page_size=${settings.pageSize}`; const resp = await apiGet(url); if (!resp || resp.status !== 200) { return { ok: false, error: `HTTP_${resp?.status || 0}`, raw: resp?.data }; } const data = unwrapLeaderboardResponse(resp.data); if (!data) { return { ok: false, error: 'BAD_JSON_SHAPE', raw: resp.data }; } return { ok: true, data }; } // ✅核心:429 不停止,等 30 秒再试 async function fetchPageWithRetry(page) { let normalAttempt = 0; let hit429 = 0; while (true) { if (stopFlag) return { ok: false, error: 'STOPPED' }; const r = await fetchPage(page); if (r.ok) return r; const is403 = (r.error === 'HTTP_403'); const is429 = (r.error === 'HTTP_429'); if (is403) return { ok: false, error: r.error }; if (is429) { hit429++; if (settings.max429RetriesPerPage && hit429 > settings.max429RetriesPerPage) { return { ok: false, error: 'HTTP_429_TOO_MANY' }; } const waitMs = (settings.retry429DelayMs || 30000) + Math.floor(Math.random() * 2000); const msg = `第 ${page} 页被限速:HTTP_429|等待 ${Math.round(waitMs / 1000)} 秒后重试(第 ${hit429} 次)`; meta.lastError = msg; saveMeta(meta); renderPanel(); toast(msg, 2500); await sleep(waitMs); continue; } // 其它错误:走普通 retryCount normalAttempt++; const msg = `第 ${page} 页失败(${r.error}),第 ${normalAttempt}/${settings.retryCount} 次`; meta.lastError = msg; saveMeta(meta); renderPanel(); toast(msg, 1800); if (normalAttempt >= settings.retryCount) return { ok: false, error: 'RETRY_EXHAUSTED' }; const backoff = settings.retryBaseDelayMs * normalAttempt + Math.floor(Math.random() * 500); await sleep(backoff); } } function calcDelay() { return settings.delayMs + Math.floor(Math.random() * settings.jitterMs); } function ingestPageItems(items, list, idxById, idxByName) { if (!Array.isArray(items)) return; for (const it of items) { const id = it.user_id; const name = it.username; const bal = formatBalance(it.available_balance); const row = [id, name, bal]; const idx = list.length; list.push(row); idxById[String(id)] = idx; if (name) idxByName[String(name).toLowerCase()] = idx; } } async function startFetch({ force = false, resume = false, startPage = 1 } = {}) { if (running) return; if (!acquireLock()) { toast('另一个标签页正在抓取(或锁未释放),请稍后再试'); return; } // ✅每天一次限制:如果是 resume 且 partial=true,允许继续 if (!force && !canFetchToday() && !(resume && meta.partial)) { toast('今日已刷新过一次(已按“每天一次”限制阻止)'); releaseLock(); return; } running = true; stopFlag = false; lockTimer = setInterval(heartbeatLock, LOCK_HEARTBEAT); heartbeatLock(); initBridge(); // ✅决定 beginPage let beginPage = Math.max(1, Math.floor(Number(startPage) || 1)); // ✅续传:沿用已有缓存 let newList = []; let newIdxById = {}; let newIdxByName = {}; const canResume = resume && meta.partial && cache.list.length > 0; if (canResume) { newList = cache.list.slice(); newIdxById = { ...cache.idxById }; newIdxByName = { ...cache.idxByName }; const nextPage = (meta.fetchedPages || 0) + 1; beginPage = Math.max(beginPage, nextPage); meta.lastError = ''; saveMeta(meta); renderPanel(); toast(`继续抓取:从第 ${beginPage}/${meta.pages || '?'} 页开始`, 2500); } else { // 非续传:清空重来 beginPage = 1; newList = []; newIdxById = {}; newIdxByName = {}; meta.partial = true; meta.fetchedPages = 0; meta.fetchedItems = 0; meta.total = 0; meta.pages = 0; meta.pageSize = settings.pageSize; meta.lastError = ''; saveMeta(meta); renderPanel(); toast('开始抓取排行榜(注意:几万条会花一段时间)', 2600); } try { // ✅确保知道 total/pages(如果没有就抓第1页拿 meta;beginPage=1 时顺便 ingest) if (!meta.pages || !meta.total) { const firstMeta = await fetchPageWithRetry(1); if (!firstMeta.ok) throw new Error(`第一页失败:${firstMeta.error}`); const total = Number(firstMeta.data.total) || 0; const pages = total ? Math.ceil(total / settings.pageSize) : 0; meta.total = total; meta.pages = pages; meta.pageSize = settings.pageSize; saveMeta(meta); renderPanel(); if (beginPage === 1) { ingestPageItems(firstMeta.data.items, newList, newIdxById, newIdxByName); meta.fetchedPages = 1; meta.fetchedItems = newList.length; saveMeta(meta); renderPanel(); toast(`已获取第 1/${pages} 页`, 1200); } } const pages = meta.pages || 0; if (!pages) throw new Error('无法确定总页数(可能未登录 credit.linux.do 或被拦截)'); if (beginPage > pages) { toast(`开始页 ${beginPage} 超过总页数 ${pages},已停止`, 3500); throw new Error(`beginPage(${beginPage}) > pages(${pages})`); } // ✅主循环:从 beginPage 开始(如果 beginPage=1,第1页已处理,则从2开始) let loopStart = beginPage; if (beginPage === 1) loopStart = 2; for (let p = loopStart; p <= pages; p++) { if (stopFlag) throw new Error('用户停止'); await sleep(calcDelay()); const r = await fetchPageWithRetry(p); if (!r.ok) throw new Error(`第 ${p} 页失败:${r.error}`); ingestPageItems(r.data.items, newList, newIdxById, newIdxByName); meta.fetchedPages = p; meta.fetchedItems = newList.length; meta.lastError = ''; saveMeta(meta); renderPanel(); // 分段落盘 if (p % settings.saveEveryPages === 0) { saveCache({ list: newList, idxById: newIdxById, idxByName: newIdxByName }); toast(`已缓存到第 ${p}/${pages} 页(已落盘)`, 1200); } else { toast(`已获取第 ${p}/${pages} 页`, 900); } } // ✅完成:落盘 + 更新 meta saveCache({ list: newList, idxById: newIdxById, idxByName: newIdxByName }); cache = { list: newList, idxById: newIdxById, idxByName: newIdxByName }; meta.partial = false; meta.lastFullFetchDay = dayKeyLocal(); meta.lastFullFetchTs = Date.now(); meta.lastError = ''; saveMeta(meta); toast(`✅ 抓取完成:共 ${cache.list.length} 条`, 3500); renderPanel(); if (settings.injectEnabled) { cleanupWrongBadges(); // 先清理误插入的 scheduleDecorate(document); } } catch (err) { meta.lastError = String(err?.message || err); saveMeta(meta); toast(`❌ 抓取中断:${meta.lastError}`, 4200); renderPanel(); // 中断时:依旧把当前 newList 落盘(避免丢) try { saveCache({ list: newList, idxById: newIdxById, idxByName: newIdxByName }); cache = { list: newList, idxById: newIdxById, idxByName: newIdxByName }; meta.partial = true; saveMeta(meta); } catch {} } finally { running = false; stopFlag = false; if (lockTimer) clearInterval(lockTimer); lockTimer = null; releaseLock(); renderPanel(); } } function stopFetch() { if (!running) return; stopFlag = true; toast('已请求停止(将在当前页结束后停)', 2000); } /*********************** * 7) 页面注入:用户名旁显示余额(跳过头像/侧边栏/导航) ***********************/ function lookupInfo(userId, username) { let idx = null; if (userId != null) idx = cache.idxById[String(userId)] ?? null; if (idx == null && username) idx = cache.idxByName[String(username).toLowerCase()] ?? null; if (idx == null) return null; const row = cache.list[idx]; if (!row) return null; return { userId: row[0], username: row[1], balance: row[2], rank: idx + 1 }; } function findUserIdNear(el) { const holder = el.closest('article[data-user-id]') || el.closest('[data-user-id]'); if (!holder) return null; const v = holder.getAttribute('data-user-id'); if (!v) return null; const n = Number(v); return Number.isFinite(n) ? n : null; } function findUsernameFromLink(a) { const uc = a.getAttribute('data-user-card'); if (uc) return uc; return null; } function isAvatarUserLink(a) { // 头像链接通常包着 img.avatar 或位于 avatar 容器 if (a.querySelector && a.querySelector('img.avatar')) return true; if (a.closest && a.closest('.topic-avatar, .poster-avatar, .avatar-wrapper, .user-image, .user-card-avatar')) return true; // 没有文字也大概率不是用户名文本链接 const txt = (a.textContent || '').trim(); if (!txt) return true; return false; } function isBadAreaLink(a) { // 侧边栏/导航/头部/菜单里不要插 return !!a.closest('.sidebar-wrapper, .sidebar-sections, .sidebar-section-link-wrapper, nav, header, .d-header, .menu-panel'); } function ensureBadgeForLink(a) { if (!settings.injectEnabled) return; if (!a || a.nodeType !== 1) return; if (isBadAreaLink(a)) return; if (isAvatarUserLink(a)) return; const username = findUsernameFromLink(a); if (!username) return; // ✅关键:只处理带 data-user-card 的“用户名链接”,跳过所有 /u/ 导航链接 const userId = findUserIdNear(a); const info = lookupInfo(userId, username); if (!info) return; const key = userId != null ? `id:${userId}` : `u:${String(username).toLowerCase()}`; // 优先插到用户名同一行的勋章容器 const namesWrap = a.closest('.names') || a.closest('.trigger-user-card') || a.parentElement; let targetContainer = null; if (namesWrap) targetContainer = namesWrap.querySelector('.poster-icon-container') || null; const scope = targetContainer || namesWrap || a.parentElement || document; const existed = scope.querySelector(`.ldc-badge[data-ldc-key="${CSS.escape(key)}"]`); const title = `Credit余额:${info.balance}|排名 #${info.rank}|user_id=${info.userId}`; if (existed) { if (existed.getAttribute('data-ldc-balance') !== info.balance) { existed.setAttribute('data-ldc-balance', info.balance); existed.title = title; const num = existed.querySelector('.ldc-num'); if (num) num.textContent = info.balance; } return; } const badge = document.createElement('span'); badge.className = 'ldc-badge'; badge.setAttribute('data-ldc-key', key); badge.setAttribute('data-ldc-balance', info.balance); badge.title = title; badge.innerHTML = `💰${escapeHtml(info.balance)}`; if (targetContainer) targetContainer.appendChild(badge); else a.insertAdjacentElement('afterend', badge); } function removeAllBadges() { document.querySelectorAll('.ldc-badge[data-ldc-key]').forEach(el => el.remove()); } function cleanupWrongBadges() { // 清理插在头像区/侧边栏/导航的 badge(历史遗留) document.querySelectorAll('.ldc-badge[data-ldc-key]').forEach(b => { if (b.closest('.topic-avatar, .poster-avatar, .avatar-wrapper, .user-image, .user-card-avatar') || b.closest('.sidebar-wrapper, .sidebar-sections, .sidebar-section-link-wrapper, nav, header, .d-header, .menu-panel')) { b.remove(); } }); } // MutationObserver:增量装饰 const pendingRoots = new Set(); let decorateTimer = null; function scheduleDecorate(root) { if (!settings.injectEnabled) return; if (root && root.nodeType === 1) pendingRoots.add(root); else pendingRoots.add(document); if (decorateTimer) return; decorateTimer = setTimeout(() => { decorateTimer = null; flushDecorate(); }, 350); } function flushDecorate() { if (!settings.injectEnabled) return; const roots = Array.from(pendingRoots); pendingRoots.clear(); for (const r of roots) decorateContainer(r); } // ✅只抓 a[data-user-card](避免 sidebar 的 /u/ 链接误伤) function decorateContainer(root) { if (!root || (root.nodeType !== 1 && root !== document)) return; const links = (root === document) ? document.querySelectorAll('a[data-user-card]') : root.querySelectorAll ? root.querySelectorAll('a[data-user-card]') : []; for (const a of links) ensureBadgeForLink(a); } function initObserver() { const mo = new MutationObserver((mutations) => { for (const m of mutations) { for (const n of (m.addedNodes || [])) { if (n && n.nodeType === 1) scheduleDecorate(n); } } }); mo.observe(document.body, { childList: true, subtree: true }); } /*********************** * 8) 启动 ***********************/ function startup() { createPanel(); cleanupWrongBadges(); renderPanel(); if (settings.injectEnabled) { scheduleDecorate(document); initObserver(); } GM_registerMenuCommand('打开/关闭面板', () => { panelState.collapsed = !panelState.collapsed; savePanelState(); const btn = panelEl.querySelector('button[data-act="collapse"]'); btn.textContent = panelState.collapsed ? '➕' : '➖'; panelEl.classList.toggle('collapsed', panelState.collapsed); bodyEl.style.display = panelState.collapsed ? 'none' : ''; }); GM_registerMenuCommand('开始获取(遵守每天一次)', () => startFetch({ force: false, resume: false, startPage: 1 })); GM_registerMenuCommand('继续获取(断点续传)', () => startFetch({ force: false, resume: true, startPage: (meta.fetchedPages || 0) + 1 })); GM_registerMenuCommand('强制刷新(不建议频繁)', () => startFetch({ force: true, resume: false, startPage: 1 })); GM_registerMenuCommand('清空缓存', () => { if (!confirm('确定清空缓存吗?')) return; cache = { list: [], idxById: {}, idxByName: {} }; meta = { lastFullFetchDay: null, lastFullFetchTs: 0, total: 0, pages: 0, pageSize: settings.pageSize, fetchedPages: 0, fetchedItems: 0, partial: false, lastError: '' }; saveCache(cache); saveMeta(meta); removeAllBadges(); renderPanel(); toast('已清空缓存'); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startup, { once: true }); } else { startup(); } })();