VJS | VJudge 原题(洛谷 + UOJ + QOJ + LOJ 合并搜索)

在 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');
    };

})();