// ==UserScript== // @name GenGen-RMJ-JL // @namespace http://tampermonkey.net/ // @version 4.6.7 // @author GenGen 队 // @match https://www.luogu.com.cn/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect codeforces.com // @connect atcoder.jp // @connect kenkoooo.com // @run-at document-end // @description GenGen RMJ (Codeforces / AtCoder 记录) // @license MIT // ==/UserScript== (async function () { 'use strict'; if (document.readyState !== 'complete') await new Promise(r => addEventListener('load', r, { once: true })); // ========== URL 检测 ========== const URL_PATH = location.pathname; const isRecordList = /^\/record\/list/.test(URL_PATH); const isLuogu = location.hostname.includes('luogu.com.cn'); if (!isLuogu) return; const params = new URLSearchParams(window.location.search); const rmj = params.get('rmj'), pid = params.get('pid') || '', contest = params.get('contest') || ''; const isCF = rmj === '1'; const isAT = rmj === '2'; // [修复] 从 GM_store 读取禁用同步开关,同时保留 URL 参数作为覆盖手段 // 当 GM_store 中 rmj-disable-sync 为 true 时,关闭所有 API 轮询 const disableSync = GM_getValue('rmj-disable-sync', false) === true || params.get('rmj-disable-sync') === 'true'; // ========== 常量 ========== const CACHE_KEY_CF = 'cf-submissions-cache'; const CACHE_KEY_AT = 'at-submissions-cache'; const LAST_UPDATE_CF = 'cf-last-update'; const LAST_UPDATE_AT = 'at-last-update'; const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; const FINAL_AT = new Set(['AC', 'WA', 'CE', 'RE', 'TLE', 'MLE', 'OLE', 'IE']); // ========== 通用工具 ========== const getCache = (key) => { try { const p = JSON.parse(GM_getValue(key, '[]')); if (!Array.isArray(p)) return []; return p.map(s => { if (s.submitUrl) { let m = s.submitUrl.match(/atcoder\.jp\/contests\/([^\/]+)\/submissions\/(\d+)/); if (m) s.submitUrl = `https://www.luogu.com.cn/record/at/${m[1]}/${m[2]}`; else { m = s.submitUrl.match(/codeforces\.com\/(?:contest|gym)\/(\d+)\/submission\/(\d+)/); if (m) s.submitUrl = `https://www.luogu.com.cn/record/cf/${m[1]}/${m[2]}`; } } if (s.luoguUrl) { let m = s.luoguUrl.match(/atcoder\.jp\/contests\/[^\/]+\/tasks\/([^\/\s?#]+)/); if (m) s.luoguUrl = `https://www.luogu.com.cn/problem/AT_${m[1]}`; else { m = s.luoguUrl.match(/codeforces\.com\/(?:problemset\/problem|(?:contest|gym)\/\d+\/problem)\/(\d+)\/([A-Za-z]\d?)/); if (m) s.luoguUrl = `https://www.luogu.com.cn/problem/CF${m[1]}${m[2]}`; } } return s; }); } catch { return []; } }; const saveCache = (key, c) => GM_setValue(key, JSON.stringify(Array.isArray(c) ? c : [])); const markUpdated = (key) => GM_setValue(key, Date.now()); const req = (url, opts = {}) => new Promise((res, rej) => { const t = setTimeout(() => rej(new Error('Timeout')), 8000); GM_xmlhttpRequest({ method: 'GET', url, ...opts, onload: r => { clearTimeout(t); res(r); }, onerror: e => { clearTimeout(t); rej(e); } }); }); // ========== AT 实时爬取核心函数 ========== const crawlATContest = async (contestId, existingCache = []) => { if (!contestId) return existingCache; try { const r = await req(`https://atcoder.jp/contests/${contestId}/submissions/me`); if (r.status !== 200) return existingCache; const doc = new DOMParser().parseFromString(r.responseText, 'text/html'); const rows = Array.from(doc.querySelectorAll('table.table tbody tr')); if (!rows.length) return existingCache; const cacheMap = new Map(existingCache.map(s => [s.id, s])); let hasNew = false; for (const row of rows) { const cells = Array.from(row.querySelectorAll('td')); if (cells.length < 7) continue; const tl = cells[1]?.querySelector('a'); const tHref = tl?.href || '', tName = tl?.innerText?.trim() || ''; const tKey = tHref.match(/\/tasks\/([^\/\s]+)/)?.[1] || ''; let rs = 'Waiting'; for (const c of cells) { const l = c.querySelector('.label'); if (l) { rs = l.innerText.trim(); break; } } const da = cells[cells.length - 1]?.querySelector('a.submission-details-link'); if (!da?.href) continue; const sid = da.href.split('/').pop(); const isF = FINAL_AT.has(rs); const prev = cacheMap.get(sid); if (!prev || prev.status !== rs) { cacheMap.set(sid, { id: sid, taskKey: tKey, taskDisplay: `${tKey} - ${tName}`, status: rs, submitUrl: `https://www.luogu.com.cn/record/at/${contestId}/${sid}`, luoguUrl: `https://www.luogu.com.cn/problem/AT_${tKey}`, timestamp: prev?.timestamp || Date.now(), isFinal: isF }); hasNew = true; } else if (isF && !prev.isFinal) { prev.isFinal = true; prev.status = rs; hasNew = true; } } if (hasNew) { return Array.from(cacheMap.values()).sort((a, b) => Number(b.id) - Number(a.id)); } return existingCache; } catch (e) { console.warn('[RMJ] AT crawl failed:', e.message || e); return existingCache; } }; // ========== 核心数据拉取函数 ========== const fetchCFData = async (handle) => { const sr = await req(`https://codeforces.com/api/user.status?handle=${encodeURIComponent(handle)}&from=1`); if (sr.status !== 200) throw new Error('CF API non-200'); const d = JSON.parse(sr.responseText); if (d.status !== 'OK') throw new Error('CF API status not OK'); const seen = new Set(); return (d.result || []).filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true; }).map(s => ({ id: String(s.id), taskDisplay: `CF${s.contestId}${s.problem.index} - ${s.problem.name}`, status: s.verdict || 'Waiting', timestamp: s.creationTimeSeconds * 1000, submitUrl: `https://www.luogu.com.cn/record/cf/${s.contestId}/${s.id}`, luoguUrl: `https://www.luogu.com.cn/problem/CF${s.contestId}${s.problem.index}`, passedTestCount: s.passedTestCount })); }; const fetchATProblems = async () => { try { const r = await req('https://kenkoooo.com/atcoder/resources/problems.json'); if (r.status !== 200) return new Map(); const m = new Map(); for (const p of JSON.parse(r.responseText)) { if (p.id) m.set(p.id, { index: p.problem_index || '', name: p.name || '' }); } return m; } catch { return new Map(); } }; const fmtATTask = (pid, pMap) => { const p = pMap.get(pid); if (p?.name && p.index) return `${pid} - ${p.index} - ${p.name}`; const parts = pid.split('_'), c = parts[0] || '', i = parts[1] ? parts[1].toUpperCase() : ''; return `${pid} - ${i ? `${c.toUpperCase()} ${i}` : pid}`; }; const fetchATSubmissions = async (pMap, uid) => { try { if (!uid) return []; const sr = await req(`https://kenkoooo.com/atcoder/atcoder-api/v3/user/submissions?user=${encodeURIComponent(uid)}&from_second=0`); if (sr.status !== 200) return []; const subs = JSON.parse(sr.responseText); if (!Array.isArray(subs)) return []; return subs.map(s => ({ id: String(s.id), taskKey: s.problem_id, taskDisplay: fmtATTask(s.problem_id, pMap), status: s.result, submitUrl: `https://www.luogu.com.cn/record/at/${s.contest_id}/${s.id}`, luoguUrl: `https://www.luogu.com.cn/problem/AT_${s.problem_id}`, timestamp: s.epoch_second * 1000, isFinal: FINAL_AT.has(s.result) })); } catch (e) { console.warn('[RMJ] Kenkoooo fetch failed:', e); return []; } }; const fetchATData = async (uid) => { const pMap = await fetchATProblems(); const subs = await fetchATSubmissions(pMap, uid); return subs.map(s => ({ ...s, taskDisplay: fmtATTask(s.taskKey, pMap) })); }; // ========== 同步函数 ========== let isCFSyncing = false; let isATSyncing = false; const syncCF = async (options = {}) => { if (isCFSyncing) return; isCFSyncing = true; try { const { render = false, renderFn = null, mainDiv = null, force = false } = options; const cache = getCache(CACHE_KEY_CF); const lastUpdate = GM_getValue(LAST_UPDATE_CF, 0); const isStale = (Date.now() - lastUpdate) > SYNC_INTERVAL_MS; if (!force && cache.length > 0 && !isStale) { if (render && renderFn && mainDiv) renderFn(cache, mainDiv); return; } const handle = GM_getValue('cf-handle', ''); if (!handle) { console.warn('[RMJ] CF handle not found'); return; } const subs = await fetchCFData(handle); saveCache(CACHE_KEY_CF, subs); markUpdated(LAST_UPDATE_CF); if (render && renderFn && mainDiv) renderFn(subs, mainDiv); } catch (e) { console.warn('[RMJ] CF sync failed:', e.message || e); } finally { isCFSyncing = false; } }; const syncAT = async (options = {}) => { if (isATSyncing) return; isATSyncing = true; try { const { render = false, renderFn = null, mainDiv = null, force = false } = options; const cache = getCache(CACHE_KEY_AT); const lastUpdate = GM_getValue(LAST_UPDATE_AT, 0); const isStale = (Date.now() - lastUpdate) > SYNC_INTERVAL_MS; if (!force && cache.length > 0 && !isStale) { if (render && renderFn && mainDiv) renderFn(cache, mainDiv); return; } const atHandle = GM_getValue('at-handle', ''); if (!atHandle) { console.warn('[RMJ] AT handle not found'); return; } const subs = await fetchATData(atHandle); const mm = new Map(); for (const s of [...subs, ...cache]) { if (!mm.has(s.id)) mm.set(s.id, s); } const merged = Array.from(mm.values()).sort((a, b) => Number(b.id) - Number(a.id)); saveCache(CACHE_KEY_AT, merged); markUpdated(LAST_UPDATE_AT); if (render && renderFn && mainDiv) renderFn(merged, mainDiv); } catch (e) { console.warn('[RMJ] AT sync failed:', e.message || e); } finally { isATSyncing = false; } }; // ========== 后台同步 ========== // 仅在未禁用同步时执行后台预加载 if (!disableSync) { syncCF({ render: false }); syncAT({ render: false }); } else { console.log('[RMJ] Sync disabled via GM_store or URL param. Running in display-only mode.'); } // ========== 记录列表页逻辑 ========== if (!isRecordList) return; if (!['1', '2'].includes(rmj)) return; const STATUS_MAP = isCF ? { OK: ['Accepted', 'cf-status-accepted'], WRONG_ANSWER: ['Wrong Answer', 'cf-status-wrong'], COMPILATION_ERROR: ['Compilation Error', 'cf-status-ce'], RUNTIME_ERROR: ['Runtime Error', 'cf-status-re'], TIME_LIMIT_EXCEEDED: ['Time Limit Exceeded', 'cf-status-limit'], MEMORY_LIMIT_EXCEEDED: ['Memory Limit Exceeded', 'cf-status-limit'], OUTPUT_LIMIT_EXCEEDED: ['Output Limit Exceeded', 'cf-status-limit'], TESTING: ['Running', 'cf-status-running'] } : { AC: ['Accepted', 'cf-status-accepted'], WA: ['Wrong Answer', 'cf-status-wrong'], CE: ['Compilation Error', 'cf-status-ce'], RE: ['Runtime Error', 'cf-status-re'], TLE: ['Time Limit Exceeded', 'cf-status-limit'], MLE: ['Memory Limit Exceeded', 'cf-status-limit'], OLE: ['Output Limit Exceeded', 'cf-status-limit'], IE: ['Internal Error', 'cf-status-ce'] }; const style = document.createElement('style'); style.textContent = ` .cf-card{display:flex;align-items:center;font-size:16px;padding:10px 0;border-bottom:1px solid #eee} .cf-avatar img{width:35px;height:35px;border-radius:50%;margin-right:12px} .cf-nameblock{display:flex;flex-direction:column;width:180px;margin-right:12px} .cf-name{font-weight:bold;color:#333;font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .cf-time{font-size:14px;color:#888;line-height:1.2;margin-top:2px;white-space:nowrap} .cf-status{flex:0 0 auto;margin-right:12px;text-align:left;width:200px} .cf-status span{border-radius:2px;padding:2px 3px;font-size:14px;font-weight:600;white-space:nowrap;display:inline-flex;align-items:center;position:relative;max-width:100%;overflow:hidden;text-overflow:ellipsis;box-sizing:border-box} .cf-status-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .cf-status-subtext{position:absolute;top:-2px;right:2px;font-size:10px!important;color:rgba(255,255,255,.8);font-weight:bold;line-height:1;pointer-events:none} .cf-status-accepted{background:rgb(82,196,26);color:#fff} .cf-status-wrong{background:rgb(231,76,60);color:#fff} .cf-status-re{background:rgb(157,61,207);color:#fff} .cf-status-ce{background:rgb(250,219,20);color:#fff} .cf-status-limit{background:rgb(5,34,66);color:#fff} .cf-status-running{background:#888;color:#fff} .cf-status-other{background:rgb(243,156,17);color:#fff} .cf-problem{flex:1;color:rgb(52,152,219);font-size:16px;text-align:left;min-width:100px;margin-left:60px} .cf-error{padding:10px;color:#e74c3c;font-weight:bold} .cf-loading{padding:10px;color:#708090;font-weight:bold} `; document.head.appendChild(style); const getUser = () => { const ne = document.querySelector("#app > div.user-nav.rside.hide.nav-scrollbar > div.title > div > span") || document.querySelector("#app > div.user-nav.rside.show.nav-scrollbar > div.title > div > span"); const ae = document.querySelector("#app > div.top-bar > div.right > div > span.avatar > a > img"); return { name: (ne?.textContent || 'User').trim(), color: ne?.style?.color || '#333', avatar: (ae?.src || 'https://cdn.luogu.com.cn/upload/usericon/default.png').trim() }; }; const fmtVerdict = v => STATUS_MAP[v] || [v.split('_').map(w => w[0] + w.slice(1).toLowerCase()).join(' '), 'cf-status-other']; const createCard = sub => { const card = document.createElement('div'); card.className = 'cf-card'; if (sub.id) card.dataset.id = sub.id; const u = getUser(); const av = document.createElement('div'); av.className = 'cf-avatar'; const img = document.createElement('img'); img.src = u.avatar; img.onerror = () => img.src = 'https://cdn.luogu.com.cn/upload/usericon/1.png'; av.appendChild(img); const nb = document.createElement('div'); nb.className = 'cf-nameblock'; const nd = document.createElement('div'); nd.className = 'cf-name'; nd.style.color = u.color; nd.textContent = u.name; const td = document.createElement('div'); td.className = 'cf-time'; td.textContent = sub.timeText; nb.append(nd, td); const sd = document.createElement('div'); sd.className = 'cf-status'; const sl = document.createElement('a'); sl.href = sub.submitUrl; sl.target = '_blank'; sl.rel = 'noopener noreferrer'; const sp = document.createElement('span'); const [full, cls] = (isCF || sub.isFinal) ? fmtVerdict(sub.status) : ['Running', 'cf-status-running']; sp.className = cls; const ts = document.createElement('span'); ts.className = 'cf-status-text'; ts.textContent = full; sp.appendChild(ts); if (isCF && sub.passedTestCount != null && !isNaN(sub.passedTestCount)) { const st = document.createElement('span'); st.className = 'cf-status-subtext'; st.textContent = String((full === 'AC' || full === 'Accepted') ? sub.passedTestCount : sub.passedTestCount + 1); sp.appendChild(st); } sl.appendChild(sp); sd.appendChild(sl); const pd = document.createElement('div'); pd.className = 'cf-problem'; const pl = document.createElement('a'); pl.href = sub.luoguUrl; pl.textContent = sub.taskDisplay; pl.target = '_blank'; pl.rel = 'noopener noreferrer'; pd.appendChild(pl); card.append(av, nb, sd, pd); return card; }; const CACHE_KEY = isCF ? CACHE_KEY_CF : CACHE_KEY_AT; const getLocalCache = () => getCache(CACHE_KEY); const saveLocalCache = (c) => saveCache(CACHE_KEY, c); const updCount = n => { const s = document.querySelector('#app > div.main-container > main > div > section > div > div > span > span'); if (s) s.textContent = String(n || 0); }; let displayedIds = new Set(), searchFilter = pid; const render = (cache, mainDiv) => { let filtered = cache; if (searchFilter) { const q = isCF ? searchFilter.toLowerCase() : searchFilter.replace(/^AT_/i, '').toLowerCase(); filtered = cache.filter(s => s.taskDisplay?.toLowerCase().includes(q)); } displayedIds.clear(); mainDiv.innerHTML = ''; updCount(filtered.length); if (!filtered.length) { const msg = document.createElement('div'); msg.className = 'cf-loading'; msg.textContent = ''; mainDiv.appendChild(msg); } else { filtered.forEach(s => { displayedIds.add(s.id); mainDiv.appendChild(createCard({ ...s, timeText: new Date(s.timestamp || 0).toLocaleString('zh-CN') })); }); } }; const waitForEl = (sel, timeout = 10000) => new Promise((res, rej) => { const el = document.querySelector(sel); if (el) return res(el); const obs = new MutationObserver(() => { const f = document.querySelector(sel); if (f) { obs.disconnect(); res(f); } }); obs.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { obs.disconnect(); rej(new Error(`Timeout: ${sel}`)); }, timeout); }); const bindSearch = async mainDiv => { try { const input = await waitForEl("#app > div.main-container.lside-nav > div > main > div > div:nth-child(1) > section:nth-child(1) > div > input:nth-child(1)", 8000); input.value = searchFilter; const orig = input.oninput; input.oninput = e => { searchFilter = e.target.value.trim(); const url = new URL(window.location); searchFilter ? url.searchParams.set('pid', searchFilter) : url.searchParams.delete('pid'); history.replaceState(null, '', url); render(getLocalCache(), mainDiv); orig?.call(input, e); }; } catch { console.warn('[RMJ] 搜索框未找到'); } }; let mainDiv = await waitForEl('#app > div.main-container.lside-nav > div > main > div > div:nth-child(2)', 10000); render(getLocalCache(), mainDiv); await bindSearch(mainDiv); // ========== CF 列表页实时更新 ========== if (isCF) { // 仅在未禁用同步时启动轮询 if (!disableSync) { let isFetching = false; const updateNow = async () => { if (isFetching) return; isFetching = true; try { const handle = GM_getValue('cf-handle', ''); if (!handle) return; const sr = await req(`https://codeforces.com/api/user.status?handle=${encodeURIComponent(handle)}&from=1`); if (sr.status !== 200) return; const d = JSON.parse(sr.responseText); if (d.status !== 'OK') return; const seen = new Set(); const subs = (d.result || []).filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true; }).map(s => ({ id: String(s.id), taskDisplay: `CF${s.contestId}${s.problem.index} - ${s.problem.name}`, status: s.verdict || 'Waiting', timestamp: s.creationTimeSeconds * 1000, submitUrl: `https://www.luogu.com.cn/record/cf/${s.contestId}/${s.id}`, luoguUrl: `https://www.luogu.com.cn/problem/CF${s.contestId}${s.problem.index}`, passedTestCount: s.passedTestCount })); saveLocalCache(subs); markUpdated(LAST_UPDATE_CF); render(subs, mainDiv); } catch (e) { console.warn('[RMJ] CF list update failed:', e.message || e); } finally { isFetching = false; } }; updateNow(); setInterval(updateNow, 6000); } } // ========== AT 列表页逻辑 ========== else { let cache = getLocalCache(); // 仅在未禁用同步时执行初始拉取和轮询 if (!disableSync) { (async () => { try { const atHandle = GM_getValue('at-handle', ''); if (!atHandle) return; const pMap = await fetchATProblems(); const kSubs = await fetchATSubmissions(pMap, atHandle); const enhanced = kSubs.map(s => ({ ...s, taskDisplay: fmtATTask(s.taskKey, pMap) })); const mm = new Map(); for (const s of [...enhanced, ...cache]) { if (!mm.has(s.id)) mm.set(s.id, s); } cache = Array.from(mm.values()).sort((a, b) => Number(b.id) - Number(a.id)); saveLocalCache(cache); markUpdated(LAST_UPDATE_AT); render(cache, mainDiv); } catch (e) { console.warn('[RMJ] AT bg load failed:', e); } })(); if (contest) { const finalized = new Set(); setInterval(async () => { const prev = getLocalCache(); const updated = await crawlATContest(contest, prev); if (updated !== prev) { cache = updated; saveLocalCache(cache); markUpdated(LAST_UPDATE_AT); render(cache, mainDiv); for (const s of updated) { if (s.isFinal) finalized.add(s.id); } } }, 6000); } } } })();