// ==UserScript==
// @name VJudge-Sync
// @namespace https://github.com/Tabris-ZX/vjudge-sync
// @version 2.2.1
// @description VJudge 一键同步归档已绑定的oj过题记录,目前支持洛谷,cf,atc,qoj,牛客
// @author Tabris_ZX
// @match https://vjudge.net/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect raw.githubusercontent.com
// @license AGPL-3.0
// @updateURL https://raw.githubusercontent.com/Tabris-ZX/vjudge-sync/main/Tampermonkey/vjudge-sync.user.js
// @downloadURL https://raw.githubusercontent.com/Tabris-ZX/vjudge-sync/main/Tampermonkey/vjudge-sync.user.js
// @connect vjudge.net
// @connect luogu.com.cn
// @connect codeforces.com
// @connect kenkoooo.com
// @connect qoj.ac
// @connect nowcoder.com
// ==/UserScript==
(function () {
'use strict';
if (!location.host.includes('vjudge.net')) return;
/*配置项*/
const GITHUB_CSS_URL = 'https://raw.githubusercontent.com/Tabris-ZX/vjudge-sync/main/Tampermonkey/panel.css';
const unarchivable_oj = new Set(['牛客']);
const language_map = new Map([['C++', '2'], ['Java', '4'], ['Python3', '11'], ['C', '39']]);
/* ================= 加载 CSS 样式 ================= */
function injectCSS(cssText) {
if (typeof GM_addStyle !== 'undefined') {
GM_addStyle(cssText);
} else {
const styleEl = document.createElement('style');
styleEl.innerHTML = cssText;
document.head.appendChild(styleEl);
}
}
function loadCSS() {
GM_xmlhttpRequest({
method: 'GET',
url: GITHUB_CSS_URL,
onload: function (res) {
if (res.status === 200) injectCSS(res.responseText);
else console.error('GitHub CSS加载失败,状态码:', res.status);
},
onerror: function (err) {
console.error('GitHub CSS请求失败:', err);
}
});
}
loadCSS();
/* ================= 2. 构建 UI DOM ================= */
const panel = document.createElement('div');
panel.id = 'vj-sync-panel';
panel.innerHTML = `
`;
document.body.appendChild(panel);
/* ================= 3. 交互逻辑 (拖拽、折叠、存储) ================= */
const header = document.getElementById('vj-sync-header');
const toggleBtn = document.getElementById('vj-toggle-btn');
const content = document.getElementById('vj-sync-body');
const logBox = document.getElementById('vj-sync-log');
// --- 恢复位置 ---
const savedPos = JSON.parse(localStorage.getItem('vj_panel_pos') || '{"top":"100px","right":"20px"}');
// 简单的防止溢出屏幕检查
if (parseInt(savedPos.top) > window.innerHeight - 50) savedPos.top = '100px';
panel.style.top = savedPos.top;
panel.style.right = 'auto';
panel.style.left = savedPos.left || 'auto';
if (!savedPos.left) panel.style.right = savedPos.right;
let isCollapsed = localStorage.getItem('vj_panel_collapsed') === 'true';
if (isCollapsed) {
content.style.display = 'none';
toggleBtn.textContent = '+';
}
// 恢复各 OJ 的勾选状态
['vj-lg', 'vj-cf', 'vj-atc', 'vj-qoj', 'vj-nc'].forEach(id => {
const saved = localStorage.getItem(id + '_checked');
if (saved === 'true') {
const el = document.getElementById(id);
if (el) el.checked = true;
}
});
['vj-lg', 'vj-cf', 'vj-atc', 'vj-qoj', 'vj-nc'].forEach(id => {
document.getElementById(id).addEventListener('change', (e) => {
localStorage.setItem(id + '_checked', e.target.checked);
});
});
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
isCollapsed = !isCollapsed;
content.style.display = isCollapsed ? 'none' : 'block';
toggleBtn.textContent = isCollapsed ? '+' : '−';
localStorage.setItem('vj_panel_collapsed', isCollapsed);
});
let isDragging = false;
let dragStart = {x: 0, y: 0};
let panelStart = {x: 0, y: 0};
header.addEventListener('mousedown', (e) => {
if (e.target === toggleBtn) return;
isDragging = true;
dragStart = {x: e.clientX, y: e.clientY};
const rect = panel.getBoundingClientRect();
panelStart = {x: rect.left, y: rect.top};
header.style.cursor = 'grabbing';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStart.x;
const dy = e.clientY - dragStart.y;
const newLeft = panelStart.x + dx;
const newTop = panelStart.y + dy;
panel.style.left = newLeft + 'px';
panel.style.top = newTop + 'px';
panel.style.right = 'auto';
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
header.style.cursor = 'move';
localStorage.setItem('vj_panel_pos', JSON.stringify({
left: panel.style.left,
top: panel.style.top
}));
}
});
// --- 按钮事件 ---
document.getElementById('vj-sync-btn').onclick = async function () {
const btn = this;
btn.disabled = true;
btn.textContent = '同步中...';
logBox.innerHTML = '';
vjArchived = {};
const needLg = document.getElementById('vj-lg').checked;
const needCf = document.getElementById('vj-cf').checked;
const needAtc = document.getElementById('vj-atc').checked;
const needQoj = document.getElementById('vj-qoj').checked;
const needNc = document.getElementById('vj-nc').checked;
fetchVJudgeArchived(() => {
const tasks = [];
if (needLg) {
tasks.push(verifyAccount('洛谷').then(account => {
if (account == null) log('❌未找到洛谷账号信息');
else fetchLuogu(account.match(/\/user\/(\d+)/)[1]);
})
);
}
if (needCf) {
tasks.push(verifyAccount('CodeForces').then(account => {
if (account == null) log('❌未找到CodeForces账号信息');
else fetchCodeForces(account.replace(/<[^>]*>/g, ''));
})
);
}
if (needAtc) {
tasks.push(verifyAccount('AtCoder').then(account => {
if (account == null) log('❌未找到AtCoder账号信息');
else fetchAtCoder(account.replace(/<[^>]*>/g, ''));
})
);
}
if (needQoj) {
tasks.push(verifyAccount('QOJ').then(account => {
if (account == null) log('❌未找到QOJ账号信息');
else fetchQOJ(account.replace(/<[^>]*>/g, ''));
})
);
}
if (needNc) {
tasks.push(verifyAccount('牛客').then(account => {
if (account == null) log('❌未找到牛客账号信息');
else fetchNowCoder(account.match(/\/profile\/(\d+)/)[1]);
})
);
}
Promise.all(tasks).finally(() => {
btn.disabled = false;
btn.textContent = '一键同步';
});
});
};
let nc_id;
let vjArchived = {};
function log(msg) {
logBox.style.display = 'block';
logBox.innerHTML += `${msg}
`;
logBox.scrollTop = logBox.scrollHeight;
}
function getVJudgeUsername() {
const urlMatch = location.pathname.match(/\/user\/([^\/]+)/);
if (urlMatch) return urlMatch[1];
const userLink = document.querySelector('a[href^="/user/"]');
if (userLink) {
const match = userLink.getAttribute('href').match(/\/user\/([^\/]+)/);
if (match) return match[1];
}
return null;
}
//检查vj登录状态
function fetchVJudgeArchived(callback) {
const username = getVJudgeUsername();
if (!username) {
log('VJudge未登录');
vjArchived = {};
if (callback) callback();
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: `https://vjudge.net/user/solveDetail/${username}`,
onload: res => {
try {
const json = JSON.parse(res.responseText);
vjArchived = json.acRecords || {};
let total = 0;
for (let k in vjArchived) total += vjArchived[k].length;
log(`VJudge已AC ${total} 题`);
if (callback) callback();
} catch (err) {
log('获取VJ记录失败');
if (callback) callback();
}
}
});
}
// --- 各个OJ的获取逻辑 ---
function fetchLuogu(user) {
log('🔄正在同步洛谷数据...');
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.luogu.com.cn/user/${user}/practice`,
headers: {'X-Lentille-Request': 'content-only'},
onload: res => {
try {
const json = JSON.parse(res.responseText);
const passed = json?.data?.passed || [];
const pids = passed.map(x => x.pid);
submitVJ('洛谷', pids);
} catch (err) {
log('洛谷数据解析失败');
}
},
onerror: () => log('洛谷请求失败')
});
}
async function fetchNowCoder(user) {
log('🔄正在同步牛客数据...');
nc_id = user;
try {
const fst = await ncGet(`https://ac.nowcoder.com/acm/contest/profile/${user}/practice-coding?pageSize=1&statusTypeFilter=5&page=1`)
const cnt = new DOMParser().parseFromString(fst.responseText, "text/html");
const totalPage = Math.ceil(Number(cnt.querySelector(".my-state-item .state-num")?.innerText)/ 200);
let pids = [];
for (let i = 1; i <= totalPage; i++) {
try {
const data = await ncGet(`https://ac.nowcoder.com/acm/contest/profile/${user}/practice-coding?pageSize=200&statusTypeFilter=5&orderType=ASC&page=${i}`)
const problems = getNcDetail(data);
pids = pids.concat(problems);
} catch (e) {
log(`牛客第 ${i} 页获取失败`);
}
}
const uniquePids = Array.from(new Map(pids.map(item => [item.problemId, item])).values());
submitVJ('牛客', uniquePids);
} catch (e) {
console.error(e)
log('牛客数据获取失败,请检查 token 是否正确或稍后再试');
}
}
function fetchCodeForces(user) {
log('正在同步CF数据...');
GM_xmlhttpRequest({
method: 'GET',
url: `https://codeforces.com/api/user.status?handle=${user}`,
onload: res => {
try {
const result = JSON.parse(res.responseText).result || [];
const pids = result
.filter(r => r.verdict === 'OK')
.map(r => `${r.problem.contestId}${r.problem.index}`);
const uniquePids = [...new Set(pids)];
submitVJ('CodeForces', uniquePids);
} catch (err) {
log('CF数据解析失败');
}
},
onerror: () => log('CF请求失败')
});
}
//数据来源:https://github.com/kenkoooo/AtCoderProblems
function fetchAtCoder(user) {
log('🔄正在同步AtCoder数据...');
GM_xmlhttpRequest({
method: 'GET',
url: `https://kenkoooo.com/atcoder/atcoder-api/v3/user/submissions?user=${user}&from_second=0`,
onload: res => {
try {
const list = JSON.parse(res.responseText) || [];
const pids = list
.filter(r => r.result === 'AC')
.map(r => `${r.problem_id}`);
const uniquePids = [...new Set(pids)];
submitVJ('AtCoder', uniquePids);
} catch (err) {
log('ATC数据解析失败');
}
},
onerror: () => log('ATC请求失败')
});
}
function fetchQOJ(user) {
log('🔄正在同步QOJ数据...');
GM_xmlhttpRequest({
method: 'GET',
url: `https://qoj.ac/user/profile/${user}`,
onload: res => {
try {
const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
const pids = [];
doc.querySelectorAll('p.list-group-item-text a').forEach(a => pids.push(a.textContent.trim()));
submitVJ('QOJ', pids);
} catch (err) {
log('QOJ解析失败');
}
},
onerror: () => log('QOJ请求失败')
});
}
// 检查 VJudge 上是否已绑定指定 OJ 账号
function verifyAccount(oj) {
log(`🔄正在检查${oj}账号信息...`);
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://vjudge.net/user/verifiedAccount?oj=${oj}`,
onload: res => {
try {
const data = JSON.parse(res.responseText);
const account = data && data.accountDisplay ? data.accountDisplay : null;
resolve(account);
} catch (err) {
resolve(null);
}
},
onerror: () => log(`${oj}请求失败`)
});
});
}
// --- 提交逻辑 ---
async function submitVJ(oj, pids) {
log(`${oj}:发现${pids.length} AC`);
const archivedSet = new Set(vjArchived[oj] || []);
let successCnt = 0;
if (!unarchivable_oj.has(oj)) {
for (const pid of pids) {
if (archivedSet.has(pid)) continue; // 已提交过
const key = `${oj}-${pid}`;
try {
const resp = await fetch(`https://vjudge.net/problem/submit/${key}`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'method=2&language=&open=0&source='
});
const result = await resp.json()
if (result && result.runId) {
successCnt++;
log(`✅${oj} ${pid} success!`);
} else log(`❌${oj} ${pid} failed!\n${result.error}`);
} catch (err) {
log(`❌${oj} ${pid} 提交失败:`, err);
}
await new Promise(resolve => setTimeout(resolve, 50));
}
} else {
for (const problem of pids) {
if (archivedSet.has(problem.problemId)) continue; // 已提交过
const key = `${oj}-${problem.problemId}`;
try {
const codeResp = await ncGet(`https://ac.nowcoder.com/acm/contest/view-submission?submissionId=${problem.submitId}&returnHomeType=1&uid=${nc_id}`);
const code = getNcCode(codeResp.responseText || '');
const resp = await fetch(`https://vjudge.net/problem/submit/${key}`, {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `method=1&language=${encodeURIComponent(problem.language)}&open=1&source=${encodeURIComponent(code)}`
});
const result = await resp.json()
if (result && result.runId) {
successCnt++;
log(`✅${oj} ${problem.problemId} success!`);
} else {
log(`❌${oj} ${problem.problemId} failed!\n${result.error}`);
}
} catch (err) {
log(`❌${oj} ${problem.problemId} 提交失败:`, err);
console.log(err)
}
await new Promise(resolve => setTimeout(resolve, 6000));
}
}
log(`🌟${oj}: 同步完成,更新 ${successCnt} 题`);
}
//不能归档的oj专用函数(目前只有牛客)
const headers = {cookie: 't=23D4F038EFBB4D806311285491E06B25'};//人机cookie
function ncGet(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, headers,
onload: res => resolve(res),
onerror: err => reject(err),
});
});
}
function getNcDetail(data) {
const result = [];
const doc = new DOMParser().parseFromString(data.responseText, "text/html");
doc.querySelectorAll("table.table-hover tbody tr").forEach(tr => {
const tds = tr.querySelectorAll("td");
if (tds.length < 8) return;
const submitId = tds[0].innerText.trim();
const problemLink = tds[1].querySelector("a")?.getAttribute("href") || "";
const problemId = problemLink.split("/").pop();
const language = language_map.get(tds[7].innerText.trim());
result.push({problemId, submitId, language});
});
return result;
}
function getNcCode(html) {
const re = /]*>([\s\S]*?)<\/pre>/i;
const match = html.match(re);
if (!match) return '';
const origCode = match[1];
return origCode
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
.replace(/"/g, '"').replace(/'/g, "'");
}
}
)
();