NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Gen-CF-RMJ 记录 // @namespace http://tampermonkey.net/ // @version 2.0 // @description 在洛谷提交记录页显示 Codeforces 提交记录,支持无限缓存、精准刷新、搜索筛选、正确链接与智能动画 // @author liyifan202201 // @match https://www.luogu.com.cn/record/list* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect codeforces.com // @license MIT // ==/UserScript== (function () { 'use strict'; if (!window.location.href.startsWith('https://www.luogu.com.cn/record/list')) { return; } // --- 变量声明 --- const mainContainerSelector = 'main > div > div'; let cfButton = null; let cfContent = null; let refreshTimer = null; let searchFilter = ''; let cachedSubmissions = []; let displayedSubmissionIds = new Set(); // 跟踪已渲染的提交 ID let isProcessingPid = false; // --- 样式注入 --- 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; width: 250px; text-align: left; } .cf-status span { border-radius: 2px; padding: 4px 8px; font-size: 14px; font-weight: 600; white-space: nowrap; display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } .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-other { background: rgb(243, 156, 17); color: #fff; } .cf-status-tle { background: rgb(5, 34, 66); color: #fff; } .cf-status-ce { background: rgb(250, 219, 20); color: #fff; } .cf-status-running { background: #888; 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: 20px; background: #fee; color: #900; border-radius: 6px; text-align: center; } .cf-loading { padding: 40px; text-align: center; color: #999; } .cf-login-warning { padding: 20px; background: #fff3cd; color: #856404; border: 1px solid #ffeaa7; border-radius: 6px; text-align: center; margin: 10px 0; } #luogu-cf-toggle-btn { padding: 8px 16px; border-radius: 4px; background: linear-gradient(45deg, #00bfff, #0095c7); color: white; font-size: 14px; border: none; cursor: pointer; transition: background 0.3s ease; flex-shrink: 0; } #luogu-cf-toggle-btn:hover { background: linear-gradient(45deg, #4DD0FF, #3CA2C8); } #cf-container { width: 100%; } .cf-search-info { padding: 10px; background: #f8f9fa; border-radius: 4px; margin: 10px 0; font-size: 14px; color: #666; } .cf-cache-indicator { display: inline-block; padding: 2px 6px; background: #e9ecef; border-radius: 3px; font-size: 12px; margin-left: 8px; color: #6c757d; } `; document.head.appendChild(style); // --- 缓存 --- const STORAGE_KEY = 'cf-submissions-cache'; function saveToCache(submissions) { try { GM_setValue(STORAGE_KEY, JSON.stringify({ timestamp: Date.now(), data: submissions })); console.log(`[CF] 缓存 ${submissions.length} 条记录`); } catch (e) { console.error('[CF] 缓存失败:', e); } } function loadFromCache() { try { const raw = GM_getValue(STORAGE_KEY, null); if (!raw) return []; const parsed = JSON.parse(raw); // ✅ 从 data 字段读取 const data = parsed.data || []; console.log(`[CF] 加载缓存 ${data.length} 条`); return data; } catch (e) { console.error('[CF] 加载缓存失败:', e); return []; } } // --- 工具函数 --- function getLuoguUserInfo() { const nameEl = document.querySelector('div.header:nth-child(1) span a span'); const avatarEl = document.querySelector('#app .user-nav a img'); return { name: nameEl ? nameEl.textContent.trim() : 'User', color: nameEl ? (nameEl.style.color || '#333') : '#333', avatar: avatarEl ? avatarEl.src : 'https://cdn.luogu.com.cn/upload/usericon/default.png' }; } function isCodeforcesMode() { return new URLSearchParams(window.location.search).has('codeforces'); } function convertCfTimeToUtc8(cfTimeStr) { if (!cfTimeStr) return 'Unknown Time'; const parts = cfTimeStr.replace(/\//g, ' ').split(' '); if (parts.length < 4) return cfTimeStr; const [monthStr, dayStr, yearStr, timeStr] = parts; const months = { 'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5, 'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11 }; const month = months[monthStr]; if (month === undefined) return cfTimeStr; const day = parseInt(dayStr, 10), year = parseInt(yearStr, 10); const [h, m] = timeStr.split(':').map(Number); if (isNaN(day) || isNaN(year) || isNaN(h) || isNaN(m)) return cfTimeStr; const utc = new Date(Date.UTC(year, month, day, h - 3, m)); if (isNaN(utc.getTime())) return cfTimeStr; const utc8 = new Date(utc.getTime() + 8 * 60 * 60 * 1000); const y = utc8.getUTCFullYear(); const mo = String(utc8.getUTCMonth() + 1).padStart(2, '0'); const d = String(utc8.getUTCDate()).padStart(2, '0'); const ho = String(utc8.getUTCHours()).padStart(2, '0'); const mi = String(utc8.getUTCMinutes()).padStart(2, '0'); return `${y}/${mo}/${d} ${ho}:${mi}`; } // --- DOM 操作 --- function removeCFIntegratedSpan() { const selectors = ['span', 'a.clear-filter', '.block > div > span', '.text.lfe-form-sz-small']; document.querySelectorAll('#app main section div section.block div').forEach(div => { selectors.forEach(sel => div.querySelectorAll(sel).forEach(el => el.remove())); }); } function lockUserFilterInput() { const input = document.querySelector('#app main section div section:nth-child(1) div div:nth-child(2) input'); if (input) { input.readOnly = true; input.style.backgroundColor = '#f5f5f5'; input.style.cursor = 'not-allowed'; input.title = '在CF模式下此筛选框已锁定'; } } function createButton() { if (cfButton) return cfButton; cfButton = document.createElement('button'); cfButton.id = 'luogu-cf-toggle-btn'; cfButton.textContent = isCodeforcesMode() ? '返回洛谷记录' : '查看 Codeforces 记录'; cfButton.style.marginLeft = '12px'; cfButton.onclick = () => { const url = new URL(window.location); if (isCodeforcesMode()) { url.searchParams.delete('codeforces'); } else { url.searchParams.set('codeforces', '1'); } window.location.href = url.toString(); }; return cfButton; } function createCfRowFromData(sub, userInfo) { let statusClass = 'cf-status-other'; const v = sub.verdict; if (/limit exceeded/i.test(v)) statusClass = 'cf-status-tle'; else if (/Runtime error/i.test(v)) statusClass = 'cf-status-re'; else if (/Compilation error/i.test(v)) statusClass = 'cf-status-ce'; else if (/Running/i.test(v)) statusClass = 'cf-status-running'; else if (/Accepted/i.test(v)) statusClass = 'cf-status-accepted'; else if (/(Wrong|Error|Rejected|Failed)/i.test(v)) statusClass = 'cf-status-wrong'; const card = document.createElement('div'); card.className = 'cf-card'; card.dataset.id = sub.id; const avatarDiv = document.createElement('div'); avatarDiv.className = 'cf-avatar'; const img = document.createElement('img'); img.src = userInfo.avatar; img.alt = "Avatar"; img.onerror = () => img.src = 'https://cdn.luogu.com.cn/upload/usericon/1.png'; avatarDiv.appendChild(img); const nameBlock = document.createElement('div'); nameBlock.className = 'cf-nameblock'; const nameDiv = document.createElement('div'); nameDiv.className = 'cf-name'; nameDiv.style.color = userInfo.color; nameDiv.textContent = userInfo.name; const timeDiv = document.createElement('div'); timeDiv.className = 'cf-time'; timeDiv.textContent = sub.timeText; nameBlock.appendChild(nameDiv); nameBlock.appendChild(timeDiv); const statusDiv = document.createElement('div'); statusDiv.className = 'cf-status'; const link = document.createElement('a'); link.href = sub.cfSubmissionLink; link.target = '_blank'; link.rel = 'noopener noreferrer'; const span = document.createElement('span'); span.className = statusClass; span.textContent = v; link.appendChild(span); statusDiv.appendChild(link); const problemDiv = document.createElement('div'); problemDiv.className = 'cf-problem'; const probLink = document.createElement('a'); probLink.href = sub.problemLink; probLink.textContent = sub.problemId; probLink.target = '_blank'; probLink.rel = 'noopener noreferrer'; problemDiv.appendChild(probLink); card.appendChild(avatarDiv); card.appendChild(nameBlock); card.appendChild(statusDiv); card.appendChild(problemDiv); return card; } // --- 渲染(带动画)--- function renderCFFromCache(isRefresh = false) { if (!cfContent) return; const userInfo = getLuoguUserInfo(); let filtered = cachedSubmissions; if (searchFilter) { const q = searchFilter.replace(/CF/gi, '').trim().toLowerCase(); filtered = cachedSubmissions.filter(s => s.problemId.toLowerCase().includes(q)); } // 首次加载:清空已显示记录,确保带动画 if (!isRefresh) { displayedSubmissionIds.clear(); } const frag = document.createDocumentFragment(); const info = document.createElement('div'); info.className = 'cf-search-info'; if (searchFilter) { info.textContent = `搜索: "${searchFilter}",找到 ${filtered.length} 条记录`; } else { info.innerHTML = `显示 ${filtered.length} 条记录 <span class="cf-cache-indicator">缓存</span>`; } frag.appendChild(info); if (filtered.length === 0) { const err = document.createElement('div'); err.className = 'cf-error'; err.textContent = '没有找到匹配的提交记录'; frag.appendChild(err); } else { let newCount = 0; filtered.forEach(sub => { const card = createCfRowFromData(sub, userInfo); if (!displayedSubmissionIds.has(sub.id)) { // 新提交:带动画 card.style.opacity = '0'; card.style.transform = 'translateY(-10px)'; card.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; setTimeout(() => { card.style.opacity = '1'; card.style.transform = 'translateY(0)'; }, newCount * 50); newCount++; displayedSubmissionIds.add(sub.id); } else { // 已存在:直接显示(状态可能已更新) card.style.opacity = '1'; card.style.transform = 'translateY(0)'; } frag.appendChild(card); }); } cfContent.innerHTML = ''; cfContent.appendChild(frag); } // --- 搜索框绑定 --- function setupSearchFilter() { const input = document.querySelector('#app main section div section:nth-child(1) div div:nth-child(1) input'); if (!input) return; input.placeholder = '筛选Codeforces题目...'; const urlParams = new URLSearchParams(window.location.search); const pid = urlParams.get('pid'); if (pid && isCodeforcesMode()) { input.value = pid; searchFilter = pid; } const original = input.oninput; input.oninput = (e) => { if (isProcessingPid) return; searchFilter = e.target.value.trim(); const url = new URL(window.location); if (searchFilter) url.searchParams.set('pid', searchFilter); else url.searchParams.delete('pid'); history.replaceState(null, '', url.toString()); renderCFFromCache(true); // 刷新时保留已有动画状态 if (original) original.call(input, e); }; } // --- 网络请求 --- function fetchAndRenderCF(isInitial) { if (!cfContent) return; GM_xmlhttpRequest({ method: 'GET', url: 'https://codeforces.com/problemset/status?my=on', onload(res) { if (res.finalUrl.includes('/enter') || res.responseText.includes('Enter')) { cfContent.innerHTML = `<div class="cf-login-warning"><strong>请登录 Codeforces</strong><br>检测到您未登录,请先登录</div>`; return; } if (res.status !== 200) { cfContent.innerHTML = `<div class="cf-error">加载失败: ${res.status}</div>`; return; } try { const doc = new DOMParser().parseFromString(res.responseText, 'text/html'); const rows = [...doc.querySelectorAll('.datatable tbody tr[data-submission-id]')] .filter(r => r.querySelector('.submissionVerdictWrapper')?.textContent?.trim()); const fresh = rows.slice(0, 50).map(row => { const id = row.getAttribute('data-submission-id'); const verdict = row.querySelector('.submissionVerdictWrapper')?.textContent?.trim() || 'Unknown'; const rawTime = row.querySelector('td:nth-child(2)')?.textContent?.trim() || ''; const problemId = row.querySelector('td:nth-child(4) a')?.textContent?.trim() || 'Unknown'; let problemLink = '#'; const m = problemId.match(/^(\d+)([A-Za-z][A-Za-z0-9]*)/); if (m) problemLink = `https://www.luogu.com.cn/problem/CF${m[1]}${m[2]}`; const cfSubmissionLink = id ? `https://codeforces.com/problemset/submission/${m[1]}/${id}` : '#'; return { id, verdict, timeText: convertCfTimeToUtc8(rawTime), problemId, problemLink, cfSubmissionLink, rawTime }; }); const freshMap = new Map(fresh.map(s => [s.id, s])); const updated = [...fresh]; // 新的在前 const seen = new Set(fresh.map(s => s.id)); cachedSubmissions.forEach(s => { if (!seen.has(s.id)) { updated.push(s); seen.add(s.id); } }); cachedSubmissions = updated; saveToCache(cachedSubmissions); renderCFFromCache(!isInitial); } catch (e) { console.error('[CF] 解析失败:', e); if (cfContent.children.length === 0) { cfContent.innerHTML = `<div class="cf-error">解析出错: ${e.message}</div>`; } } }, onerror(err) { console.error('[CF] 网络错误:', err); if (cachedSubmissions.length > 0) renderCFFromCache(true); else if (cfContent) cfContent.innerHTML = `<div class="cf-error">网络错误</div>`; } }); } // --- 模式切换 --- function switchToCodeforces() { const mainDiv = document.querySelector(mainContainerSelector); if (!mainDiv) return; cachedSubmissions = loadFromCache(); cfContent = document.createElement('div'); cfContent.id = 'cf-container'; cfContent.innerHTML = '<div class="cf-loading">正在加载 Codeforces 提交记录...</div>'; mainDiv.innerHTML = ''; mainDiv.appendChild(cfContent); const btn = createButton(); btn.textContent = '返回洛谷记录'; const target = document.querySelector('main section > div > section > b:first-child'); if (target && !target.parentNode.contains(btn)) { target.parentNode.insertBefore(btn, target.nextSibling || null); } removeCFIntegratedSpan(); lockUserFilterInput(); setupSearchFilter(); if (cachedSubmissions.length > 0) { renderCFFromCache(false); // 首次加载带动画 } fetchAndRenderCF(true); if (refreshTimer) clearInterval(refreshTimer); refreshTimer = setInterval(() => { if (isCodeforcesMode() && cfContent) { fetchAndRenderCF(false); removeCFIntegratedSpan(); } else { clearInterval(refreshTimer); } }, 3000); } function switchToLuogu() { if (refreshTimer) clearInterval(refreshTimer); displayedSubmissionIds.clear(); const url = new URL(window.location); url.searchParams.delete('codeforces'); window.location.href = url.toString(); } // --- URL 监听 --- function monitorUrlParams() { if (isProcessingPid) return; const pid = new URLSearchParams(window.location.search).get('pid'); if (pid && isCodeforcesMode()) { isProcessingPid = true; const input = document.querySelector('#app main section div section:nth-child(1) div div:nth-child(1) input'); if (input && input.value !== pid) { input.value = pid; searchFilter = pid; setTimeout(() => { if (cachedSubmissions.length > 0) renderCFFromCache(true); isProcessingPid = false; }, 100); } else { isProcessingPid = false; } } } function monitorUrlMode() { if (isCodeforcesMode()) { if (!cfContent) switchToCodeforces(); removeCFIntegratedSpan(); lockUserFilterInput(); monitorUrlParams(); } else { if (cfContent) switchToLuogu(); } } // --- 初始化 --- function insertButton() { if (!cfButton) createButton(); const target = document.querySelector('main section > div > section > b:first-child'); if (target && cfButton && !target.parentNode.contains(cfButton)) { target.parentNode.insertBefore(cfButton, target.nextSibling || null); } } const observer = new MutationObserver(() => { insertButton(); monitorUrlMode(); }); function start() { const main = document.querySelector('main'); if (main) { observer.observe(main, { childList: true, subtree: true }); setTimeout(() => { insertButton(); monitorUrlMode(); }, 100); } else { setTimeout(start, 500); } } window.addEventListener('popstate', () => { setTimeout(() => { monitorUrlMode(); if (cfButton) cfButton.textContent = isCodeforcesMode() ? '返回洛谷记录' : '查看 Codeforces 记录'; }, 0); }); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } })();