VJudge 比赛显示原题链接

在 VJudge 比赛题目页添加原题链接

// ==UserScript==
// @name         VJudge 比赛显示原题链接
// @namespace    https://vjudge.net/
// @version      0.1
// @description  在 VJudge 比赛题目页添加原题链接
// @match        https://vjudge.net/contest/*
// @license      MIT
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const ANCHOR_CONTAINER_ID = 'vjudge-original-link-container';
  const DEBOUNCE_MS = 120;

  let titleObserver = null;
  let bodyObserver = null;
  let debounceTimer = null;
  let lastDataJsonRaw = null;

  function getProblemLetterFromHash() {
    const m = location.hash.match(/#problem\/([^/?&]+)/);
    return m ? m[1] : null;
  }

  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 JSON.parse(raw);
    try {
      const parsed = JSON.parse(raw);
      lastDataJsonRaw = raw;
      return parsed;
    } catch (e) {
      console.warn('vjudge-original-link: JSON parse failed', e);
      return null;
    }
  }

  function simpleObjHash(obj) {
    try {
      return String(JSON.stringify(obj));
    } catch (e) {
      return String(obj);
    }
  }

  function ensureLinkContainerForProblem(problem, letter) {
    const titleEl = document.getElementById('prob-title-contest');
    if (!titleEl) return;

    const existing = document.getElementById(ANCHOR_CONTAINER_ID);

    const targetHash = problem ? simpleObjHash({ oj: problem.oj, pid: problem.pid, num: problem.num, title: problem.title }) : '';
    const wantLetter = letter || '';

    if (existing) {
      if (existing.dataset.letter === wantLetter && existing.dataset.targetHash === targetHash) {
        return; // nothing to do
      }
      existing.remove();
    }

    if (!problem) return;

    const container = document.createElement('div');
    container.id = ANCHOR_CONTAINER_ID;
    container.style.marginTop = '6px';
    container.style.fontSize = '14px';
    container.style.lineHeight = '1.4';

    container.dataset.letter = wantLetter;
    container.dataset.targetHash = targetHash;

    const oj = (problem.oj || '').toString().toLowerCase();
    const title = problem.title || '';
    let a = document.createElement('a');
    a.style.textDecoration = 'none';
    a.style.fontWeight = '500';
    a.target = '_blank';
    a.rel = 'noopener noreferrer';

    if (oj.includes('luogu') || oj.includes('洛谷') || oj.includes('luogu.com')) {
      const probNum = problem.probNum || problem.probID || problem.pid || null;
      if (probNum) {
        a.href = `https://www.luogu.com.cn/problem/${encodeURIComponent(probNum)}`;
        a.textContent = `原题:洛谷 ${probNum}`;
      } else {
        a.removeAttribute('href');
        a.textContent = '原题:暂不支持';
        a.style.cursor = 'default';
      }
    } else if (oj.includes('qoj')) {
      const q = encodeURIComponent(title || '');
      a.href = `https://qoj.ac/problems?search=${q}`;
      a.textContent = '原题:QOJ(点击搜索)';
    } else if (oj.includes('codeforces')) {
      const q = encodeURIComponent(title || '');
      a.href = `https://www.luogu.com.cn/problem/list?keyword=${q}&type=CF&page=1`;
      a.textContent = '原题:Codeforces(点击搜索)';
    } else if (oj.includes('atcoder')) {
      const probNum = problem.probNum || problem.probID || problem.pid || null;
      const contestId = probNum.slice(0, probNum.lastIndexOf("_"));
      a.href = `https://atcoder.jp/contests/${contestId}/tasks/${probNum}`;
      a.textContent = `原题:AtCoder ${probNum}`;
    } else if (oj.includes('uva')) {
      const q = encodeURIComponent(title || '');
      a.href = `https://www.luogu.com.cn/problem/list?keyword=${q}&type=UVA&page=1`;
      a.textContent = '原题:UVA(点击搜索)';
    } else if (oj.includes('universaloj')) {
      const q = encodeURIComponent(title || '');
      a.href = `https://uoj.ac/problems?search=${q}`;
      a.textContent = '原题:UOJ(点击搜索)';
    } else if (oj.includes('libreoj')) {
      const q = encodeURIComponent(title || '');
      a.href = `https://loj.ac/p?keyword=${q}`;
      a.textContent = '原题:LOJ(点击搜索)';
    } else {
      a.removeAttribute('href');
      a.textContent = `原题:来自 ${oj},暂不支持查找`;
      a.style.cursor = 'default';
    }

    container.appendChild(a);

    try {
      titleEl.appendChild(container);
    } catch (e) {
      console.warn('vjudge-original-link: insert failed', e);
    }
  }

  // 主要更新逻辑(防抖)
  function handleUpdate() {
    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;

        ensureLinkContainerForProblem(target, letter);
      } catch (err) {
        console.error('vjudge-original-link: handleUpdate error', err);
      }
    }, DEBOUNCE_MS);
  }

  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();
              }, 20);
              return;
            }
            if (n && n.id === 'prob-title-contest') {
              setTimeout(() => {
                startTitleObserver();
                handleUpdate();
              }, 20);
              return;
            }
          }
        }
      }
    });
    bodyObserver.observe(document.body, { childList: true, subtree: true });
  }

  function stopBodyObserver() {
    if (bodyObserver) {
      try { bodyObserver.disconnect(); } catch (e) {}
      bodyObserver = null;
    }
  }

  function tryInit(retries = 10) {
    const titleEl = document.getElementById('prob-title-contest');
    if (titleEl) {
      startTitleObserver();
      startBodyObserver();
      handleUpdate();
    } else if (retries > 0) {
      setTimeout(() => tryInit(retries - 1), 300);
    } else {
      startBodyObserver();
    }
  }

  window.addEventListener('hashchange', () => {
    setTimeout(handleUpdate, 80);
  });

  // run
  tryInit();

  window.__vjudge_original_link_cleanup = function () {
    stopTitleObserver();
    stopBodyObserver();
    const ex = document.getElementById(ANCHOR_CONTAINER_ID);
    if (ex) ex.remove();
    console.info('vjudge-original-link: cleaned up');
  };

})();