// ==UserScript== // @name GenGen-RMJ // @icon https://cdn.luogu.com.cn/upload/image_hosting/3s3czya0.png // @namespace https://gengen.qzz.io/ // @version 4.0 // @description GenGen RMJ 4.0 完整重构版 // @author GenGen 队 // @run-at document-start // @match https://challenges.cloudflare.com/cdn-cgi/challenge-platform/* // @match https://www.luogu.com.cn/* // @match https://atcoder.jp/contests/*/submit?RMJ=1 // @match https://codeforces.com/* // @require https://cdn.bootcdn.net/ajax/libs/html2canvas/1.4.1/html2canvas.js // @require https://cdn.bootcdn.net/ajax/libs/sweetalert2/11.23.0/sweetalert2.all.js // @require https://scriptcat.org/scripts/code/7193/GenGen-RMJ-JL.user.js // @require https://scriptcat.org/scripts/code/5096/GenGen-Cloudflare-RMJ.user.js // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @license MIT // ==/UserScript== (function () { 'use strict'; /* ═══════════════ 常量 ═══════════════ */ const VER = '4.0.0'; const DATE = '2026/7/27'; const POLL = 2000; const TAG = '[RMJ 4.0]'; /* ═══════════════ 日志 ═══════════════ */ const log = (...a) => console.log(TAG, ...a); const warn = (...a) => console.warn(TAG, ...a); const err = (...a) => console.error(TAG, ...a); /* ═══════════════ 工具 ═══════════════ */ const onReady = fn => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn(); const waitEl = (sel, cb, ms = 12000) => { const t0 = Date.now(); const iv = setInterval(() => { const el = document.querySelector(sel); if (el) { clearInterval(iv); cb(el); } else if (Date.now() - t0 > ms) { clearInterval(iv); warn('waitEl 超时:', sel); } }, 120); }; const isLuogu = () => location.hostname === 'www.luogu.com.cn'; const isCF = () => location.hostname === 'codeforces.com'; const isAT = () => location.hostname === 'atcoder.jp'; /* ═══════════════ 样式 ═══════════════ */ GM_addStyle(` /* ── 按钮 ── */ .rmj-btn { background: linear-gradient(135deg, #0ea5e9, #2563eb); color: #fff !important; border: none; border-radius: 8px; font-family: 'JetBrains Mono','Fira Code',monospace; padding: 5px 14px; font-weight: 700; font-size: 14px; cursor: pointer; margin-right: 10px; box-shadow: 0 3px 12px rgba(37,99,235,.30); transition: all .22s ease; height: 32px; display: inline-flex; align-items: center; gap: 6px; user-select: none; letter-spacing: .3px; } .rmj-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(37,99,235,.45); } .rmj-btn:active { transform: translateY(0) scale(.97); } .rmj-btn img { width: 18px; height: 18px; border-radius: 4px; } /* ── 面板 ── */ #rmj-panel { position: fixed; width: 440px; background: rgba(255,255,255,.97); backdrop-filter: blur(14px) saturate(1.5); border: 1px solid rgba(37,99,235,.12); border-radius: 16px; box-shadow: 0 24px 64px rgba(0,0,0,.14), inset 0 0 0 1px rgba(255,255,255,.7); z-index: 2147483647; padding: 22px 24px; font-size: 14px; display: flex; flex-direction: column; gap: 10px; opacity: 0; visibility: hidden; transform: translateY(-14px) scale(.95); transition: all .28s cubic-bezier(.4,0,.2,1); pointer-events: none; } #rmj-panel.show { opacity: 1; visibility: visible; transform: translateY(0) scale(1); pointer-events: all; } .rmj-hd { display: flex; align-items: center; justify-content: center; gap: 10px; padding-bottom: 14px; border-bottom: 2px solid #eef2f7; margin-bottom: 2px; } .rmj-hd img { width: 34px; height: 34px; border-radius: 8px; } .rmj-hd h3 { margin: 0; font-size: 24px; font-weight: 800; background: linear-gradient(135deg,#0ea5e9,#2563eb); -webkit-background-clip: text; font-family: 'JetBrains Mono',monospace; } .rmj-sec { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 1px; margin-top: 6px; } .rmj-row { display: flex; justify-content: space-between; align-items: center; padding: 7px 12px; border-radius: 8px; background: #f6f8fb; } .rmj-row .lb { color: #555; font-weight: 500; } .rmj-ok { color: #16a34a; font-weight: 700; } .rmj-no { color: #dc2626; font-weight: 600; } .rmj-dim { color: #94a3b8; font-weight: 500; } .rmj-a { color: #2563eb; text-decoration: none; font-weight: 600; font-size: 13px; } .rmj-a:hover { text-decoration: underline; } .rmj-ft { font-size: 11px; color: #b0b8c4; text-align: center; margin-top: 4px; } .rmj-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; } .rmj-badge.on { background: #dcfce7; color: #16a34a; } .rmj-badge.off { background: #fee2e2; color: #dc2626; } /* ── 做题状态覆盖层(透明背景) ── */ .rmj-overlay { position: absolute; inset: 0; background: transparent; display: flex; align-items: center; justify-content: center; z-index: 10; border-radius: inherit; } .rmj-overlay svg { width: 18px; height: 18px; } .rmj-hide-orig > *:not(.rmj-overlay) { visibility: hidden !important; } /* ── Problem 页状态标签 ── */ .rmj-prob-status { display: inline-flex; align-items: center; transition: all .3s; } .rmj-prob-status.ac { color: #16a34a;font-weight: bold;} .rmj-prob-status.wa { color: #dc2626;font-weight: bold;} .rmj-prob-status.na { color: rgb(52, 152, 219);} `); /* ═══════════════ SVG ═══════════════ */ const SVG_OK = ``; const SVG_ERR = ``; const SVG_NONE = ``; /* ═══════════════ CF Handle 捕获 ═══════════════ */ function captureCF() { if (!isCF()) return; log('CF: 尝试捕获 Handle…'); const tryCapture = () => { const el = document.querySelector('#header > div.lang-chooser > div:nth-child(2) > a:nth-child(1)'); if (!el) { warn('CF: 元素未找到,1s 后重试'); setTimeout(tryCapture, 1000); return; } const txt = el.textContent.trim(); log(`CF: 检测到文本 "${txt}"`); if (txt && txt !== 'Enter' && txt !== '登陆' && txt !== '登录') { const old = GM_getValue('cf-handle', ''); if (old !== txt) { GM_setValue('cf-handle', txt); log(`CF Handle 更新: "${old}" → "${txt}"`); } else log(`CF Handle 不变: "${txt}"`); } else log('CF: 未登录,跳过'); }; tryCapture(); } /* ═══════════════ AT Handle 捕获 ═══════════════ */ function captureAT() { if (!isAT()) return; log('AT: 尝试捕获 Handle…'); GM_xmlhttpRequest({ method: 'GET', url: 'https://atcoder.jp/', onload(res) { if (res.status !== 200) { warn('AT: HTTP', res.status); return; } try { const doc = new DOMParser().parseFromString(res.responseText, 'text/html'); const el = doc.querySelector('#navbar-collapse > ul.nav.navbar-nav.navbar-right > li:nth-child(2) > a'); if (!el || el.textContent.trim() === 'Sign Up') { log('AT: 未登录'); return; } const h = el.textContent.trim(); const old = GM_getValue('at-handle', ''); if (old !== h) { GM_setValue('at-handle', h); log(`AT Handle 更新: "${old}" → "${h}"`); } else log(`AT Handle 不变: "${h}"`); } catch (e) { err('AT: 解析失败', e); } }, onerror: () => err('AT: 网络错误'), timeout: 8000 }); } /* ═══════════════ 缓存 ═══════════════ */ let cfMap = new Map(), atMap = new Map(); let cfRawPrev = '', atRawPrev = ''; let lastRefresh = 0; function refreshCache() { try { const cfRaw = GM_getValue('cf-submissions-cache', '[]'); const atRaw = GM_getValue('at-submissions-cache', '[]'); if (cfRaw !== cfRawPrev) { cfRawPrev = cfRaw; cfMap.clear(); JSON.parse(cfRaw).forEach(s => { if (!s?.taskDisplay) return; const m = s.taskDisplay.match(/CF[A-Z0-9]+/i); if (!m) return; const k = m[0].toUpperCase(), v = s.status || null; if (!cfMap.has(k) || (cfMap.get(k) !== 'OK' && v === 'OK')) cfMap.set(k, v); }); log(`CF 缓存刷新: ${cfMap.size} 条`, [...cfMap.entries()].slice(0, 5)); } if (atRaw !== atRawPrev) { atRawPrev = atRaw; atMap.clear(); JSON.parse(atRaw).forEach(s => { if (!s?.taskKey) return; const k = s.taskKey.toLowerCase(), v = s.status || null; if (!atMap.has(k) || (atMap.get(k) !== 'AC' && v === 'AC')) atMap.set(k, v); }); log(`AT 缓存刷新: ${atMap.size} 条`, [...atMap.entries()].slice(0, 5)); } lastRefresh = Date.now(); } catch (e) { err('缓存刷新异常', e); } } /* ═══════════════ 查询单题状态 ═══════════════ */ function queryStatus(pid) { if (/^CF[A-Z0-9]+$/i.test(pid)) { const v = cfMap.get(pid.toUpperCase()); if (v === 'OK') return 'AC'; if (v) return 'WA'; return 'NONE'; } if (/^AT_/i.test(pid)) { const key = pid.replace(/^AT_/i, ''); log(`查询 AT: ${pid} → key="${key}"`); const v = atMap.get(key); log(` 缓存命中: ${v ?? '(无)'}`); if (v === 'AC') return 'AC'; if (v) return 'WA'; return 'NONE'; } return null; } /* ═══════════════ 训练 / 题目列表:覆盖层轮询 ═══════════════ */ let listTimer = null; function updateListStatus() { refreshCache(); let n = 0; document.querySelectorAll('.row').forEach(row => { const cells = row.children; if (cells.length < 2) return; const statusCell = cells[0], pidCell = cells[1]; const m = pidCell.textContent.trim().match(/^(\S+)/); if (!m) return; const pid = m[1]; const st = queryStatus(pid); if (st === null) { const ov = statusCell.querySelector('.rmj-overlay'); if (ov) { ov.remove(); statusCell.classList.remove('rmj-hide-orig'); } return; } let ov = statusCell.querySelector('.rmj-overlay'); if (!ov) { ov = document.createElement('div'); ov.className = 'rmj-overlay'; statusCell.style.position = 'relative'; statusCell.appendChild(ov); statusCell.classList.add('rmj-hide-orig'); } const icon = st === 'AC' ? SVG_OK : st === 'WA' ? SVG_ERR : SVG_NONE; if (ov.dataset.s !== st) { ov.innerHTML = icon; ov.dataset.s = st; n++; log(`列表状态: ${pid} → ${st}`); } }); if (n) log(`列表本轮更新 ${n} 项`); } function startList() { if (!listTimer) { log('启动列表轮询'); updateListStatus(); listTimer = setInterval(updateListStatus, POLL); } } function stopList() { if (listTimer) { clearInterval(listTimer); listTimer = null; log('停止列表轮询'); } } /* ═══════════════ Problem 页:轮询状态标签 ═══════════════ */ let probTimer = null; function updateProbStatus() { refreshCache(); const pid = location.pathname.split('/problem/')[1]; if (!pid) return; const st = queryStatus(pid); if (st === null) return; const link=document.querySelectorAll('a[href*="/record/list?pid="]')[0]; let tag = link.querySelector('.rmj-prob-status'); if (!tag) { // 隐藏洛谷原始 span const orig = link.querySelector('span'); if (orig) orig.style.display = 'none'; tag = document.createElement('span'); tag.className = 'rmj-prob-status'; link.appendChild(tag); } const cls = st === 'AC' ? 'ac' : st === 'WA' ? 'wa' : 'na'; const txt = st === 'AC' ? 'Accepted' : st === 'WA' ? 'Unaccepted' : '暂无'; if (tag.dataset.s !== st) { tag.className = 'rmj-prob-status ' + cls; tag.innerHTML = txt; tag.dataset.s = st; log(`Problem 页状态: ${pid} → ${st} (${txt})`); } } function startProb() { if (probTimer) return; log('启动 Problem 页轮询'); updateProbStatus(); probTimer = setInterval(updateProbStatus, POLL); } function stopProb() { if (probTimer) { clearInterval(probTimer); probTimer = null; log('停止 Problem 页轮询'); } } /* ═══════════════ 面板 ═══════════════ */ let panel = null, hideT = null; function buildPanel() { if (panel) panel.remove(); panel = document.createElement('div'); panel.id = 'rmj-panel'; const cfH = GM_getValue('cf-handle', ''); const atH = GM_getValue('at-handle', ''); const polling = !!(listTimer || probTimer); panel.innerHTML = `
RMJ`;
b.addEventListener('mouseenter', () => setTimeout(() => showPanel(b.getBoundingClientRect()), 180));
b.addEventListener('mouseleave', hidePanel);
nav.parentElement.insertBefore(b, nav);
log('首页按钮已注入');
});
}
/* ═══════════════ 洛谷:Problem 页劫持 ═══════════════ */
function hookProblem() {
const p = location.pathname;
if (!p.startsWith('/problem/CF') && !p.startsWith('/problem/AT')) return;
const rmj = p.startsWith('/problem/CF') ? '1' : '2';
const pid = p.split('/problem/')[1];
if (!pid) return;
waitEl('.side', side => {
const obs = new MutationObserver(() => {
side.querySelectorAll('a[href*="/record/list?pid="]').forEach(link => {
if (link.dataset.rmjHook) return;
link.dataset.rmjHook = '1';
link.addEventListener('click', e => {
e.preventDefault(); e.stopImmediatePropagation();
const u = new URL('/record/list', location.origin);
const sp = new URLSearchParams(location.search);
for (const [k, v] of sp) u.searchParams.set(k, v);
u.searchParams.set('pid', pid);
u.searchParams.set('rmj', rmj);
location.href = u.toString();
}, true);
// link.href = 'javascript:void(0)';
});
updateProbStatus();
});
obs.observe(side, { childList: true, subtree: true });
log('Problem 页劫持就绪, pid =', pid);
});
startProb();
}
/* ═══════════════ 洛谷:Record List 选择器 ═══════════════ */
function injectSelector() {
if (!location.pathname.startsWith('/record/list')) return;
waitEl('section b', b => {
if (b.dataset.rmj) return;
b.dataset.rmj = '1';
const s = document.createElement('select');
s.style.cssText = 'margin-left:8px;padding:2px 8px;border-radius:3px;border:1px solid #ccc;font-size:13px;';
s.innerHTML = ``;
s.value = new URLSearchParams(location.search).get('rmj') || '';
s.onchange = () => {
const p = new URLSearchParams(location.search);
s.value ? p.set('rmj', s.value) : p.delete('rmj');
location.search = p.toString();
};
b.after(s);
log('Record 选择器已注入');
});
}
/* ═══════════════ SPA 路由 ═══════════════ */
let prevPath = location.pathname;
function onRoute() {
const cur = location.pathname;
if (cur === prevPath) return;
log(`路由: ${prevPath} → ${cur}`);
const wasList = prevPath.startsWith('/training') || prevPath.startsWith('/problem/list');
const nowList = cur.startsWith('/training') || cur.startsWith('/problem/list');
const wasProb = prevPath.startsWith('/problem/CF') || prevPath.startsWith('/problem/AT');
const nowProb = cur.startsWith('/problem/CF') || cur.startsWith('/problem/AT');
if (wasList && !nowList) stopList();
if (!wasList && nowList) startList();
if (wasProb && !nowProb) stopProb();
if (!wasProb && nowProb) { hookProblem(); }
prevPath = cur;
}
/* ═══════════════ 主入口 ═══════════════ */
function main() {
log(`启动 v${VER} | ${location.hostname}${location.pathname}`);
if (isCF()) { captureCF(); return; }
if (isAT()) { captureAT(); return; }
if (!isLuogu()) return;
injectBtn();
hookProblem();
injectSelector();
if (location.pathname.startsWith('/training') || location.pathname.startsWith('/problem/list'))
startList();
setInterval(onRoute, 200);
addEventListener('beforeunload', () => { stopList(); stopProb(); });
}
onReady(main);
})();