您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在 VJudge 比赛题目页添加原题链接;并行在洛谷、UOJ、QOJ、LOJ 搜索并合并候选,优先展示洛谷/ UOJ 结果。
// ==UserScript== // @name VJS | VJudge 原题(洛谷 + UOJ + QOJ + LOJ 合并搜索) // @namespace https://vjudge.net/ // @version 1.8 // @author Oracynx (extended) // @description 在 VJudge 比赛题目页添加原题链接;并行在洛谷、UOJ、QOJ、LOJ 搜索并合并候选,优先展示洛谷/ UOJ 结果。 // @match https://vjudge.net/contest/* // @license MIT // @grant GM_xmlhttpRequest // @connect www.luogu.com.cn // @connect qoj.ac // @connect uoj.ac // @connect loj.ac // @run-at document-end // ==/UserScript== (function () { 'use strict'; const ANCHOR_CONTAINER_ID = 'vjudge-original-vjs-link'; const DEBOUNCE_MS = 140; let titleObserver = null, bodyObserver = null, debounceTimer = null; let lastDataJsonRaw = null, lastParsedData = null; let currentRequestId = 0, pendingRequestId = null; // ----- small utils ----- function parseDataJsonSafely() { const ta = document.querySelector('textarea[name="dataJson"]'); if (!ta) return null; const raw = ta.value || ta.textContent || ta.innerText || ''; if (!raw) return null; if (raw === lastDataJsonRaw) return lastParsedData; try { const parsed = JSON.parse(raw); lastDataJsonRaw = raw; lastParsedData = parsed; return parsed; } catch (e) { console.warn('vjudge-vjs: JSON parse failed', e); lastDataJsonRaw = raw; lastParsedData = null; return null; } } function simpleObjHash(obj) { try { return String(JSON.stringify(obj)); } catch { return String(obj); } } function ensureStyles() { if (document.getElementById('vjudge-vjs-styles')) return; const style = document.createElement('style'); style.id = 'vjudge-vjs-styles'; style.textContent = ` #${ANCHOR_CONTAINER_ID} { margin-top:6px; font-size:14px; line-height:1.4; display:flex; align-items:center; gap:8px } #${ANCHOR_CONTAINER_ID} .vjs-text { cursor:default } #${ANCHOR_CONTAINER_ID} .vjs-meta { margin-left:4px; font-size:12px; color:#666 } #${ANCHOR_CONTAINER_ID} .vjs-status { font-size:12px; color:#888 } #${ANCHOR_CONTAINER_ID} .vjs-action { margin-left:8px; font-size:12px; cursor:pointer; color:#0b66ff; text-decoration:underline } #${ANCHOR_CONTAINER_ID} .vjs-spinner { width:14px; height:14px; border-radius:50%; border:2px solid rgba(0,0,0,0.12); border-top-color:rgba(0,0,0,0.6); animation: vjs-spin 1s linear infinite } @keyframes vjs-spin { from { transform:rotate(0deg) } to { transform:rotate(360deg) } } #${ANCHOR_CONTAINER_ID} .vjs-click-hint { font-size:11px; color:#aaa } `; document.head.appendChild(style); } function createOrUpdateContainer(text, href, meta, state) { ensureStyles(); const titleEl = document.getElementById('prob-title-contest'); if (!titleEl) return; let existing = document.getElementById(ANCHOR_CONTAINER_ID); const newHash = simpleObjHash({ href, meta, text, state }); if (existing && existing.dataset.hash === newHash) return; if (existing) existing.remove(); const container = document.createElement('div'); container.id = ANCHOR_CONTAINER_ID; container.dataset.hash = newHash; if (state === 'loading') { const spinner = document.createElement('div'); spinner.className = 'vjs-spinner'; container.appendChild(spinner); } if (href) { const a = document.createElement('a'); a.style.textDecoration = 'none'; a.style.fontWeight = '500'; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.href = href; a.textContent = text || href; a.className = 'vjs-text'; container.appendChild(a); } else { const span = document.createElement('span'); span.className = 'vjs-text'; span.textContent = text || ''; container.appendChild(span); } if (meta) { const metaSpan = document.createElement('span'); metaSpan.className = 'vjs-meta'; metaSpan.textContent = `(${meta})`; container.appendChild(metaSpan); } const statusSpan = document.createElement('span'); statusSpan.className = 'vjs-status'; if (state === 'loading') statusSpan.textContent = '正在搜索…'; else if (state === 'success') statusSpan.textContent = '已完成'; else if (state === 'error') statusSpan.textContent = '出错'; else statusSpan.textContent = ''; container.appendChild(statusSpan); const retry = document.createElement('span'); retry.className = 'vjs-action'; retry.textContent = '重试'; retry.title = '点击重试搜索原题'; retry.addEventListener('click', (e) => { e.stopPropagation(); handleUpdate(true); }); container.appendChild(retry); const hint = document.createElement('span'); hint.className = 'vjs-click-hint'; hint.textContent = '(点击重试)'; container.appendChild(hint); try { titleEl.appendChild(container); } catch (e) { console.warn('vjudge-vjs: insert fail', e); } } // ----- GM 跨域 GET(优先) ----- function httpGetText(url, cb) { try { if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function') { GM.xmlHttpRequest({ method: 'GET', url: url, headers: { 'User-Agent': "Oracynx's Problem Searcher" }, onload: function (res) { cb(null, res.responseText); }, onerror: function (err) { cb(err || new Error('request failed')); } }); return; } } catch (e) { /* ignore */ } try { if (typeof GM_xmlhttpRequest === 'function') { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'User-Agent': "Oracynx's Problem Searcher" }, onload: function (res) { cb(null, res.responseText); }, onerror: function (err) { cb(err || new Error('request failed')); } }); return; } } catch (e) { /* ignore */ } // fallback(可能受 CORS) fetch(url, { method: 'GET', credentials: 'omit' }) .then(r => r.text().then(t => cb(null, t))) .catch(err => cb(err)); } // ----- 在洛谷上搜索并解析 lentille-context ----- function searchOnLuogu(title, typeFilter, cb) { const q = encodeURIComponent(title || ''); let type = encodeURIComponent(typeFilter || ''); if (type == 'CodeForces') type = 'CF'; if (type == 'AtCoder') type = 'AT'; const url = `https://www.luogu.com.cn/problem/list?keyword=${q}&type=${type}&page=1`; httpGetText(url, (err, text) => { if (err) return cb(err); try { const re = /<script\s+id=["']lentille-context["'][^>]*>([\s\S]*?)<\/script>/i; const m = text.match(re); if (!m || !m[1]) return cb(new Error('lentille-context not found')); const jsonText = m[1].trim(); const parsed = JSON.parse(jsonText); let candidateRoot = null; if (parsed && parsed.props && parsed.props.pageProps) candidateRoot = parsed.props.pageProps; else if (parsed && parsed.initialState) candidateRoot = parsed.initialState; else if (parsed && parsed.data) candidateRoot = parsed.data; else candidateRoot = parsed; const findArrayWithTitles = (obj) => { if (!obj || typeof obj !== 'object') return null; if (Array.isArray(obj)) { if (obj.length > 0 && obj[0] && (obj[0].title || obj[0].pid || obj[0].probNum || obj[0].id)) return obj; } for (const k of Object.keys(obj)) { try { const v = obj[k]; if (Array.isArray(v) && v.length > 0 && v[0] && (v[0].title || v[0].pid || v[0].probNum || v[0].id)) return v; if (typeof v === 'object') { const deeper = findArrayWithTitles(v); if (deeper) return deeper; } } catch (e) { } } return null; }; const arr = findArrayWithTitles(candidateRoot) || []; // 对于洛谷结果,去除开头形如 [xxx] 的前缀再作为匹配标题 const normalized = (arr || []).map(it => { const rawTitle = it.title || it.name || ''; // 去除一个或多个连续的方括号前缀,例如 [ICPC 2018 WF] Gem Island 或 [A][B] Name const cleanedTitle = String(rawTitle).replace(/^\s*(?:\[[^\]]+\]\s*)+/, ''); return { title: cleanedTitle, originalTitle: rawTitle, pid: it.pid || it.probNum || it.id || it.problemId || '', url: it.url || (it.pid ? `https://www.luogu.com.cn/problem/${it.pid}` : ''), type: it.type || 'LUOGU' }; }); cb(null, normalized); } catch (e) { cb(e); } }); } // ----- 在 QOJ 上搜索并解析问题列表页 ----- function searchOnQOJ(title, cb) { const q = encodeURIComponent(title || ''); const url = `https://qoj.ac/problems?search=${q}`; httpGetText(url, (err, text) => { if (err) return cb(err); try { let parser; try { parser = new DOMParser(); } catch (e) { parser = null; } if (parser) { const doc = parser.parseFromString(text, 'text/html'); const rows = doc.querySelectorAll('table.table tbody tr'); const results = []; rows.forEach(tr => { try { const tds = tr.querySelectorAll('td'); if (!tds || tds.length < 2) return; const idText = (tds[0].textContent || '').trim(); const idMatch = idText.match(/#?(\d+)/); const pid = idMatch ? idMatch[1] : null; const a = tds[1].querySelector('a'); const titleText = a ? (a.textContent || '').trim() : ''; const href = a ? a.getAttribute('href') : null; const urlAbs = href ? (href.startsWith('http') ? href : `https://qoj.ac${href}`) : (pid ? `https://qoj.ac/problem/${encodeURIComponent(pid)}` : ''); if (titleText) { results.push({ title: titleText, pid: pid, url: urlAbs, type: 'QOJ' }); } } catch (e) { } }); return cb(null, results); } else { const rowRe = /<tr[\s\S]*?>[\s\S]*?<td[^>]*>\s*#?(\d+)\s*<\/td>[\s\S]*?<td[^>]*>[\s\S]*?<a[^>]*href=["']([^"']+)["'][^>]*>([^<]+)<\/a>/gi; const results = []; let m; while ((m = rowRe.exec(text)) !== null) { const pid = m[1]; const href = m[2]; const titleText = m[3].trim(); const urlAbs = href.startsWith('http') ? href : `https://qoj.ac${href}`; results.push({ title: titleText, pid: pid, url: urlAbs, type: 'QOJ' }); } return cb(null, results); } } catch (e) { cb(e); } }); } // ----- 在 UOJ 上搜索并解析问题列表页 ----- function searchOnUOJ(title, cb) { const q = encodeURIComponent(title || ''); const url = `https://uoj.ac/problems?search=${q}`; httpGetText(url, (err, text) => { if (err) return cb(err); try { let parser; try { parser = new DOMParser(); } catch (e) { parser = null; } if (parser) { const doc = parser.parseFromString(text, 'text/html'); const rows = doc.querySelectorAll('table.table tbody tr'); const results = []; rows.forEach(tr => { try { const tds = tr.querySelectorAll('td'); if (!tds || tds.length < 2) return; const idText = (tds[0].textContent || '').trim(); const idMatch = idText.match(/#?(\d+)/); const pid = idMatch ? idMatch[1] : null; const a = tds[1].querySelector('a'); const titleText = a ? (a.textContent || '').trim() : ''; const href = a ? a.getAttribute('href') : null; const urlAbs = href ? (href.startsWith('http') ? href : `https://uoj.ac${href}`) : (pid ? `https://uoj.ac/problem/${encodeURIComponent(pid)}` : ''); if (titleText) { results.push({ title: titleText, pid: pid, url: urlAbs, type: 'UOJ' }); } } catch (e) { } }); return cb(null, results); } else { const rowRe = /<tr[\s\S]*?>[\s\S]*?<td[^>]*>\s*#?(\d+)\s*<\/td>[\s\S]*?<td[^>]*>[\s\S]*?<a[^>]*href=["']([^"']+)["'][^>]*>([^<]+)<\/a>/gi; const results = []; let m; while ((m = rowRe.exec(text)) !== null) { const pid = m[1]; const href = m[2]; const titleText = m[3].trim(); const urlAbs = href.startsWith('http') ? href : `https://uoj.ac${href}`; results.push({ title: titleText, pid: pid, url: urlAbs, type: 'UOJ' }); } return cb(null, results); } } catch (e) { cb(e); } }); } // 新的 searchOnLOJ:使用 LOJ 的官方查询 API(POST JSON) function searchOnLOJ(title, cb) { const url = 'https://api.loj.ac/api/problem/queryProblemSet'; const bodyObj = { locale: 'zh_CN', skipCount: 0, takeCount: 50, keyword: title || '' }; const bodyText = JSON.stringify(bodyObj); // 首选 GM 跨域请求(Tampermonkey/Violentmonkey 支持) const doCallbackWithError = (err) => { try { cb(err, []); } catch (e) { console.error('searchOnLOJ cb error', e); } }; try { if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function') { GM.xmlHttpRequest({ method: 'POST', url: url, data: bodyText, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, onload: function (res) { try { const text = res.responseText || ''; const json = JSON.parse(text); if (!json || !Array.isArray(json.result)) return cb(null, []); const results = (json.result || []).map(item => { const meta = item.meta || {}; // 优先使用 displayId 做显示 pid,如需改为 meta.id 可以调整 const pid = (meta.displayId !== undefined && meta.displayId !== null) ? String(meta.displayId) : (meta.id ? String(meta.id) : null); const idForUrl = meta.id ? String(meta.id) : pid; const urlAbs = idForUrl ? `https://loj.ac/problem/${encodeURIComponent(idForUrl)}` : null; return { title: item.title || '', pid: pid, url: urlAbs, type: 'LOJ', meta: meta }; }).filter(r => r.title); return cb(null, results); } catch (e) { console.warn('searchOnLOJ: parse error, falling back', e); return doFallbackHtmlParse(); } }, onerror: function (err) { console.warn('searchOnLOJ: GM xmlHttpRequest error', err); return doFallbackHtmlParse(); }, ontimeout: function () { console.warn('searchOnLOJ: GM xmlHttpRequest timeout'); return doFallbackHtmlParse(); }, timeout: 15000 }); return; } } catch (e) { console.warn('searchOnLOJ: GM.xmlHttpRequest not available', e); } // fallback: fetch POST (可能受 CORS/凭据限制) function tryFetchFallback() { fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: bodyText, mode: 'cors', // 注意:可能受 CORS/凭据限制 credentials: 'omit' }).then(r => r.text()).then(t => { try { const json = JSON.parse(t); if (!json || !Array.isArray(json.result)) return cb(null, []); const results = (json.result || []).map(item => { const meta = item.meta || {}; const pid = (meta.displayId !== undefined && meta.displayId !== null) ? String(meta.displayId) : (meta.id ? String(meta.id) : null); const idForUrl = meta.id ? String(meta.id) : pid; const urlAbs = idForUrl ? `https://loj.ac/problem/${encodeURIComponent(idForUrl)}` : null; return { title: item.title || '', pid: pid, url: urlAbs, type: 'LOJ', meta: meta }; }).filter(r => r.title); return cb(null, results); } catch (e) { console.warn('searchOnLOJ: fetch parse error', e); return doFallbackHtmlParse(); } }).catch(err => { console.warn('searchOnLOJ: fetch error', err); return doFallbackHtmlParse(); }); } // 兜底:如果 API 不可用(被 CORS / token 问题阻止),回退到尝试抓取 loj.ac HTML 或旧的 loj 解析 function doFallbackHtmlParse() { // 尝试从公开 loj.ac/problems?search=... 页面抓表格或 /problem/ 链接 const fallbackUrl = `https://loj.ac/problems?search=${encodeURIComponent(title || '')}`; httpGetText(fallbackUrl, (err, text) => { if (err || !text) return doCallbackWithError(err || new Error('LOJ API and fallback both failed')); try { let parser; try { parser = new DOMParser(); } catch (e) { parser = null; } if (parser) { const doc = parser.parseFromString(text, 'text/html'); const rows = doc.querySelectorAll('table.table tbody tr'); const results = []; rows.forEach(tr => { try { const tds = tr.querySelectorAll('td'); if (!tds || tds.length < 2) return; const idText = (tds[0].textContent || '').trim(); const idMatch = idText.match(/#?(\d+)/); const pid = idMatch ? idMatch[1] : null; const a = tds[1].querySelector('a'); const titleText = a ? (a.textContent || '').trim() : ''; const href = a ? a.getAttribute('href') : null; const urlAbs = href ? (href.startsWith('http') ? href : `https://loj.ac${href}`) : (pid ? `https://loj.ac/problem/${encodeURIComponent(pid)}` : ''); if (titleText) results.push({ title: titleText, pid: pid, url: urlAbs, type: 'LOJ' }); } catch (e) { } }); if (results.length > 0) return cb(null, results); } // 正则兜底匹配 /problem/<id> 链接 const rowRe = /<a[^>]*href=["']([^"']*\/problem\/(\d+)[^"']*)["'][^>]*>([^<]+)<\/a>/gi; const results2 = []; let m; while ((m = rowRe.exec(text)) !== null) { const href = m[1]; const pid = m[2]; const titleText = m[3].trim(); const urlAbs = href.startsWith('http') ? href : `https://loj.ac${href}`; results2.push({ title: titleText, pid: pid, url: urlAbs, type: 'LOJ' }); } if (results2.length > 0) return cb(null, results2); return doCallbackWithError(new Error('LOJ fallback parse found nothing')); } catch (e) { return doCallbackWithError(e); } }); } // 如果 GM 请求不可用,先用 fetch 再兜底 tryFetchFallback(); } // ----- 统一入口:并行查询洛谷 + UOJ + QOJ + LOJ,合并结果 (优先顺序:LUOGU, UOJ, QOJ, LOJ) ----- function performUnifiedSearch(payload, cb) { if (!payload || !payload.title) return cb(new Error('invalid payload')); const sources = [ { fn: (cb2) => searchOnLuogu(payload.title, payload.type || '', cb2), name: 'LUOGU' }, { fn: (cb2) => searchOnUOJ(payload.title, cb2), name: 'UOJ' }, { fn: (cb2) => searchOnQOJ(payload.title, cb2), name: 'QOJ' }, { fn: (cb2) => searchOnLOJ(payload.title, cb2), name: 'LOJ' } ]; const resultsBySource = {}; let remaining = sources.length; let anyErr = null; sources.forEach(s => { try { s.fn((err, arr) => { if (err) anyErr = anyErr || err; resultsBySource[s.name] = (arr || []); remaining--; if (remaining === 0) finalize(); }); } catch (e) { anyErr = anyErr || e; resultsBySource[s.name] = []; remaining--; if (remaining === 0) finalize(); } }); function finalize() { // 按优先级合并并去重(优先顺序:LUOGU, UOJ, QOJ, LOJ) const order = ['LUOGU', 'UOJ', 'QOJ', 'LOJ']; const combined = []; // 使用标题规范化、url、pid 三条线索去重,标题规范化为首要判断 const seenTitles = new Set(); const seenUrls = new Set(); const seenPids = new Set(); order.forEach(src => { (resultsBySource[src] || []).forEach(it => { if (!it) return; const titleNorm = it.title ? String(it.title).trim().toLowerCase() : ''; const url = it.url ? String(it.url).trim() : ''; const pid = (it.pid !== undefined && it.pid !== null) ? String(it.pid).trim() : ''; // 如果标题已有优先来源,则跳过 if (titleNorm && seenTitles.has(titleNorm)) return; // 否则如果 url 或 pid 已存在也跳过 if (url && seenUrls.has(url)) return; if (pid && seenPids.has(pid)) return; // 否则保留该条目,并标记这些键 combined.push(it); if (titleNorm) seenTitles.add(titleNorm); if (url) seenUrls.add(url); if (pid) seenPids.add(pid); }); }); // 如果合并为空但有错误,返回错误(便于调试) if (combined.length === 0 && anyErr) return cb(anyErr); const shaped = { data: { problems: { result: (combined || []).map(it => ({ title: it.title, pid: it.pid, url: it.url, type: it.type })) } } }; cb(null, shaped); } } // ----- utility: get problem letter from hash ----- function getProblemLetterFromHash() { const m = location.hash.match(/#problem\/([^/?&]+)/); return m ? m[1] : null; } // ----- core handler ----- function handleUpdate(force = false) { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { try { const letter = getProblemLetterFromHash(); if (!letter) { const ex = document.getElementById(ANCHOR_CONTAINER_ID); if (ex) ex.remove(); return; } const data = parseDataJsonSafely(); if (!data || !Array.isArray(data.problems)) { const ex2 = document.getElementById(ANCHOR_CONTAINER_ID); if (ex2) ex2.remove(); return; } const target = data.problems.find(p => { if (!p) return false; if (p.num && String(p.num).toUpperCase() === String(letter).toUpperCase()) return true; if (p.num && String(p.num).toUpperCase().includes(String(letter).toUpperCase())) return true; return false; }) || null; if (!target) { const ex = document.getElementById(ANCHOR_CONTAINER_ID); if (ex) ex.remove(); return; } const title = target.title || (target.name ? target.name : ''); const type = (target && target.oj) ? String(target.oj) : (target && target.source ? String(target.source) : ''); if (!title) { createOrUpdateContainer('原题:无法识别题目标题', null, null, 'error'); return; } createOrUpdateContainer('原题:正在搜索…(洛谷 / UOJ / QOJ / LOJ)', null, '', 'loading'); const payload = { type: type || '', title: title }; const thisRequestId = ++currentRequestId; pendingRequestId = thisRequestId; performUnifiedSearch(payload, (err, resJson) => { if (pendingRequestId !== thisRequestId) return; pendingRequestId = null; if (err) { console.warn('vjudge-vjs: unified search error', err); createOrUpdateContainer('原题:搜索失败或解析异常', null, null, 'error'); return; } try { const resultArr = (resJson && resJson.data && resJson.data.problems && resJson.data.problems.result) ? resJson.data.problems.result : null; if (!Array.isArray(resultArr) || resultArr.length === 0) { createOrUpdateContainer('原题:未找到匹配结果(洛谷/UOJ/QOJ/LOJ)', null, null, 'error'); return; } // 尽量找标题完全匹配(先精确,再忽略大小写,再取第一个) let match = resultArr.find(x => x && x.title && String(x.title) === String(title)); if (!match) match = resultArr.find(x => x && x.title && String(x.title).toLowerCase() === String(title).toLowerCase()); if (!match) match = resultArr[0]; const pid = match && (match.pid || match.probNum || match.id || match.problemId) ? (match.pid || match.probNum || match.id || match.problemId) : null; const mtype = (match && match.type) ? String(match.type).toUpperCase() : ''; if (mtype.includes('UOJ') && pid) { const uojLink = match.url || `https://uoj.ac/problem/${encodeURIComponent(pid)}`; createOrUpdateContainer(`原题: UOJ ${pid}`, uojLink, `UOJ - ${pid}`, 'success'); } else if (mtype.includes('QOJ') && pid) { const qojLink = match.url || `https://qoj.ac/problem/${encodeURIComponent(pid)}`; createOrUpdateContainer(`原题: QOJ ${pid}`, qojLink, `QOJ - ${pid}`, 'success'); } else if (mtype.includes('LOJ') && pid) { const lojLink = match.url || `https://loj.ac/problem/${encodeURIComponent(pid)}`; createOrUpdateContainer(`原题: LOJ ${pid}`, lojLink, `LOJ - ${pid}`, 'success'); } else if (mtype.includes('LUOGU') && pid) { const luoguLink = match.url || `https://www.luogu.com.cn/problem/${encodeURIComponent(pid)}`; createOrUpdateContainer(`原题: ${pid}`, luoguLink, `LUOGU - ${pid}`, 'success'); } else if (match && match.url) { createOrUpdateContainer(`原题: ${match.title || '候选'}`, match.url, `${match.type || payload.type || ''}`, 'success'); } else if (pid) { // 无法确定来源但有 pid,尝试构造常见链接(先 UOJ/QOJ/LOJ/LOUGU) const tryUrls = [ `https://uoj.ac/problem/${encodeURIComponent(pid)}`, `https://qoj.ac/problem/${encodeURIComponent(pid)}`, `https://loj.ac/problem/${encodeURIComponent(pid)}`, `https://www.luogu.com.cn/problem/${encodeURIComponent(pid)}` ]; createOrUpdateContainer(`原题: ${pid}`, tryUrls[0], `尝试:${tryUrls.join(' / ')}`, 'success'); } else { createOrUpdateContainer('原题:找到候选但无 pid/url 字段', null, null, 'error'); } } catch (e) { console.error('vjudge-vjs: parse response error', e); createOrUpdateContainer('原题:结果处理异常', null, null, 'error'); } }); } catch (err) { console.error('vjudge-vjs: handleUpdate error', err); } }, DEBOUNCE_MS); } // ----- observers ----- function startTitleObserver() { stopTitleObserver(); const titleEl = document.getElementById('prob-title-contest'); if (!titleEl) return; titleObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.addedNodes) { for (const n of m.addedNodes) { if (n && n.id === ANCHOR_CONTAINER_ID) return; if (n && n.querySelector && n.querySelector(`#${ANCHOR_CONTAINER_ID}`)) return; } } } handleUpdate(); }); titleObserver.observe(titleEl, { childList: true, subtree: true, characterData: true }); } function stopTitleObserver() { if (titleObserver) { try { titleObserver.disconnect(); } catch (e) { } titleObserver = null; } } function startBodyObserver() { if (bodyObserver) return; bodyObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.addedNodes) { for (const n of m.addedNodes) { if (n && n.querySelector && n.querySelector('#prob-title-contest')) { setTimeout(() => { startTitleObserver(); handleUpdate(); }, 30); return; } if (n && n.id === 'prob-title-contest') { setTimeout(() => { startTitleObserver(); handleUpdate(); }, 30); return; } } } } }); bodyObserver.observe(document.body, { childList: true, subtree: true }); } function stopBodyObserver() { if (bodyObserver) { try { bodyObserver.disconnect(); } catch (e) { } bodyObserver = null; } } function tryInit(retries = 12) { const titleEl = document.getElementById('prob-title-contest'); if (titleEl) { startTitleObserver(); startBodyObserver(); handleUpdate(); } else if (retries > 0) { setTimeout(() => tryInit(retries - 1), 250); } else { startBodyObserver(); } } window.addEventListener('hashchange', () => { setTimeout(handleUpdate, 80); }); tryInit(); // cleanup window.__vjudge_vjs_cleanup = function () { stopTitleObserver(); stopBodyObserver(); const ex = document.getElementById(ANCHOR_CONTAINER_ID); if (ex) ex.remove(); console.info('vjudge-vjs: cleaned up'); }; })();