// ==UserScript== // @name GenGen-RMJ-JL // @namespace http://tampermonkey.net/ // @version 4.1 // @author GenGen 队 // @match https://www.luogu.com.cn/record/list* // @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'; const VALID_URLS = [ /^https:\/\/www\.luogu\.com\.cn\/record\/list$/, /^https:\/\/www\.luogu\.com\.cn\/record\/list\?.+/, ]; if (!VALID_URLS.some(re => re.test(location.href))) return; if (document.readyState !== 'complete') await new Promise(r => addEventListener('load', r, { once: true })); const params = new URLSearchParams(window.location.search); const rmj = params.get('rmj'), pid = params.get('pid') || '', contest = params.get('contest') || ''; if (!['1', '2'].includes(rmj)) return; const isCF = rmj === '1'; // ========== 配置 ========== 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 FINAL_AT = new Set(['AC', 'WA', 'CE', 'RE', 'TLE', 'MLE', 'OLE', 'IE']); // ========== 注入样式 ========== 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:36px;height:36px;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:250px} .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 .user-nav > a > span') || document.querySelector('div.header span a span') || document.querySelector('.username'); const ae = document.querySelector('#app .user-nav 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(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 ? 'cf-submissions-cache' : 'at-submissions-cache'; const getCache = () => { try { const p = JSON.parse(GM_getValue(CACHE_KEY, '[]')); return Array.isArray(p) ? p : []; } catch { return []; } }; const saveCache = c => GM_setValue(CACHE_KEY, JSON.stringify(Array.isArray(c) ? c : [])); 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); } }); }); 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 main section div section:nth-child(1) div div:nth-child(1) input', 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(getCache(), mainDiv); orig?.call(input, e); }; } catch { console.warn('[Gen-RMJ] 搜索框未找到'); } }; // ========== 主容器 ========== let mainDiv; try { mainDiv = await waitForEl('main > div > div', 10000); } catch { mainDiv = document.createElement('div'); (document.querySelector('#app main') || document.body).appendChild(mainDiv); } render(getCache(), mainDiv); await bindSearch(mainDiv); // ========== CF 逻辑 ========== if (isCF) { let isFetching = false; const updateNow = async () => { if (isFetching) return; isFetching = true; try { // 直接从 GM 存储读取 CF handle const handle = GM_getValue('cf-handle', '') || GM_getValue('codeforces-handle', '') || GM_getValue('cf-username', ''); if (!handle) { console.warn('[Gen-RMJ] CF handle not found in GM storage (keys: cf-handle, codeforces-handle, cf-username)'); 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://codeforces.com/contest/${s.contestId}/submission/${s.id}`, luoguUrl: `https://www.luogu.com.cn/problem/CF${s.contestId}${s.problem.index}`, passedTestCount: s.passedTestCount })); saveCache(subs); render(subs, mainDiv); } catch (e) { console.warn('[Gen-RMJ] CF update failed:', e.message || e); } finally { isFetching = false; } }; updateNow(); setInterval(updateNow, 1000); } // ========== AT 逻辑 ========== else { const sanitize = (url, cid) => { if (url.startsWith('https://atcoder.jp/')) return url; const m = url.match(/luogu\.com\.cn\/contests\/([^\/]+)\/submissions\/(\d+)/); if (m) return `https://atcoder.jp/contests/${m[1]}/submissions/${m[2]}`; try { return new URL(url, `https://atcoder.jp/contests/${cid}/`).href; } catch { return `https://atcoder.jp/contests/${cid}/submissions/${url.split('/').pop()}`; } }; const fetchProbs = 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 fmtTask = (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 fetchKen = 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: fmtTask(s.problem_id, pMap), status: s.result, submitUrl: `https://atcoder.jp/contests/${s.contest_id}/submissions/${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('[Gen-RMJ] Kenkoooo failed:', e); return []; } }; let cache = getCache(); // 后台加载 Kenkoooo 全量数据 (async () => { try { const atHandle = GM_getValue('at-handle', '') || GM_getValue('atcoder-handle', '') || GM_getValue('at-username', ''); if (!atHandle) { console.warn('[Gen-RMJ] AT handle not found in GM storage (keys: at-handle, atcoder-handle, at-username)'); return; } const pMap = await fetchProbs(); const kSubs = await fetchKen(pMap, atHandle); const enhanced = kSubs.map(s => ({ ...s, taskDisplay: fmtTask(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)); saveCache(cache); render(cache, mainDiv); } catch (e) { console.warn('[Gen-RMJ] AT bg failed:', e); } })(); // 实时轮询(如有 contest) if (contest) { const finalized = new Set(); setInterval(async () => { try { const r = await req(`https://atcoder.jp/contests/${contest}/submissions/me`); if (r.status !== 200) return; const doc = new DOMParser().parseFromString(r.responseText, 'text/html'); const row = doc.querySelector('table.table tbody tr'); if (!row) return; const cells = Array.from(row.querySelectorAll('td')); if (cells.length < 7) return; 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) return; const sid = da.href.split('/').pop(), isF = FINAL_AT.has(rs); if (isF && finalized.has(sid)) return; const idx = cache.findIndex(s => s.id === sid); const ne = { id: sid, taskKey: tKey, taskDisplay: `${tKey} - ${tName}`, status: rs, submitUrl: `https://atcoder.jp/contests/${contest}/submissions/${sid}`, luoguUrl: `https://www.luogu.com.cn/problem/AT_${tKey}`, timestamp: idx >= 0 ? cache[idx].timestamp : Date.now(), isFinal: isF }; if (idx >= 0) cache[idx] = ne; else cache.unshift(ne); saveCache(cache); render(cache, mainDiv); if (isF) finalized.add(sid); } catch (e) { console.warn('[Gen-RMJ] AT poll failed:', e.message || e); } }, 1000); } } })();