QOJ Better

Make QOJ great again!

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         QOJ Better
// @namespace    http://tampermonkey.net/
// @version      1.9
// @description  Make QOJ great again!
// @match        https://qoj.ac/*
// @match        https://jiang.ly/*
// @match        https://huang.lt/*
// @match        https://contest.ucup.ac/*
// @match        https://oj.qiuly.org/*
// @match        https://relia.uk/*
// @match        https://love.larunatre.cy/*
// @grant        none
// @license      MIT
// @author       cyx
// ==/UserScript==

// 全局状态
window.onlyUCUPTeams = false;
window.cachedProblemIndices = null;

const RATING_CONFIG = { BASE: 4100, K: 950 };

// 获取题号
function getProblemId() {
    const matchContest = location.pathname.match(/\/contest\/(\d+)\/problem\/(\d+)/);
    if (matchContest) return matchContest[2];
    const matchProblem = location.pathname.match(/\/problem\/(\d+)/);
    if (matchProblem) return matchProblem[1];
    return null;
}

// 获取用户名
function getUsername() {
    const userLink = document.querySelector('a.dropdown-item[href*="/user/profile/"]');
    if (userLink) {
        const match = userLink.href.match(/\/user\/profile\/([^/?#]+)/);
        if (match) return match[1];
    }
    return null;
}

function switchDomain() {
    if (document.getElementById('domain-switcher')) return;
    const currentHost = location.host;
    const pathname = location.pathname + location.search + location.hash;
    const isContest =
        pathname.includes('/contest/') ||
        pathname.includes('/contests') ||
        pathname.includes('/user') ||
        pathname.includes('/results');
    const domains = isContest
        ? ['qoj.ac', 'jiang.ly', 'huang.lt', 'oj.qiuly.org', 'relia.uk', 'love.larunatre.cy', 'contest.ucup.ac']
        : ['qoj.ac', 'jiang.ly', 'huang.lt', 'oj.qiuly.org', 'relia.uk', 'love.larunatre.cy'];

    // 构造域名切换内容
    const span = document.createElement('span');
    span.id = 'domain-switcher';
    span.style.fontSize = '0.9em';
    span.style.color = '#666';
    span.textContent = 'switch to: ';

    domains.forEach((domain, i) => {
        const link = document.createElement('a');
        link.textContent = domain;
        link.style.marginLeft = '4px';
        link.style.color = domain === currentHost ? '#999' : '#007bff';
        link.style.cursor = domain === currentHost ? 'default' : 'pointer';
        link.style.textDecoration = 'none';
        link.onmouseover = () => (link.style.textDecoration = 'underline');
        link.onmouseout = () => (link.style.textDecoration = 'none');
        if (domain !== currentHost) {
            link.onclick = () => (window.location.href = `https://${domain}${pathname}`);
        }
        span.appendChild(link);
        if (i < domains.length - 1) span.append(' ');
    });

    // 优先尝试插入到顶部的 float-right nav(登录区域)
    const navPills = document.querySelector('.nav.nav-pills.float-right');
    if (navPills && !navPills.querySelector('#domain-switcher')) {
        const li = document.createElement('li');
        li.className = 'nav-item';
        li.appendChild(span);
        navPills.insertBefore(li, navPills.firstChild);
        return;
    }

    // 如果有登录的用户(navbar 中的 dropdown-toggle)
    const navbarUser = document.querySelector('.navbar .nav-link.dropdown-toggle');
    if (navbarUser) {
        const parentUl = navbarUser.closest('ul');
        if (parentUl && !parentUl.querySelector('#domain-switcher')) {
            const li = document.createElement('li');
            li.style.listStyle = 'none';
            li.appendChild(span);

            const userLi = navbarUser.closest('li');
            if (userLi && userLi.parentElement) {
                userLi.parentElement.insertBefore(li, userLi);
            }
        }
        return;
    }

    document.body.insertBefore(span, document.body.firstChild);
}

// ========== UCUP 评分功能 ==========

function getMultiplier(x) {
    if (x <= 20) return 1.0;
    if (x <= 40) return 1.01;
    if (x <= 60) return 1.03;
    if (x <= 70) return 1.05;
    if (x <= 80) return 1.08;
    if (x <= 90) return 1.12;
    if (x <= 95) return 1.20;
    if (x === 96) return 1.30;
    if (x === 97) return 1.50;
    if (x === 98) return 1.80;
    if (x === 99) return 2.50;
    if (x >= 100) return 3.50;
    return 1.0;
}

function getStyle(r) {
    if (r >= 3000) return '#AA0000';
    if (r >= 2600) return '#FF3333';
    if (r >= 2400) return '#FF7777';
    if (r >= 2100) return '#FFBB55';
    if (r >= 1900) return '#FF88FF';
    if (r >= 1600) return '#AAAAFF';
    if (r >= 1400) return '#03A89E';
    return '#77FF77';
}

function isProblemHeader(text) {
    if (!text) return false;
    text = text.trim();
    return /^[A-Z][0-9]?($|[\s\n\(\)])/.test(text);
}

function getProblemIndices() {
    // 如果已缓存,返回缓存的值
    if (window.cachedProblemIndices !== null) {
        return window.cachedProblemIndices;
    }

    const table = document.querySelector('table');
    if (!table) return [];

    let headerRow = table.querySelector('thead tr') || table.rows[0];
    if (!headerRow) return [];

    const cells = Array.from(headerRow.cells);
    const problemIndices = [];

    cells.forEach((cell, idx) => {
        const text = cell.innerText || cell.textContent;
        if (isProblemHeader(text)) {
            problemIndices.push(idx);
        }
    });

    // 缓存起来
    if (problemIndices.length > 0) {
        window.cachedProblemIndices = problemIndices;
    }

    return problemIndices;
}

function calculateRatings() {
    const table = document.querySelector('table');
    if (!table) return;

    let headerRow = table.querySelector('thead tr') || table.rows[0];
    if (!headerRow) return;

    if (typeof standings === 'undefined' || !Array.isArray(standings)) return;
    if (typeof score === 'undefined' || typeof score !== 'object') return;

    // 使用缓存的题目索引
    const problemIndices = getProblemIndices();
    if (problemIndices.length === 0) return;

    let validTeams = standings.filter(row => {
        if (!Array.isArray(row) || row.length < 3) return false;
        const userInfo = row[2];
        if (!userInfo || !Array.isArray(userInfo)) return false;

        if (window.onlyUCUPTeams) {
            const userId = userInfo[0];
            return userId.startsWith('ucup-team');
        }
        return true;
    });

    const totalParticipants = validTeams.length;

    // 当没有有效队伍时,也需要显示 4000 分
    if (totalParticipants === 0) {
        // 没有符合条件的队伍,所有题目都显示 4000
        problemIndices.forEach((columnIdx) => {
            const th = headerRow.cells[columnIdx];
            let badge = th.querySelector('.qoj-precise-rating');

            if (!badge) {
                badge = document.createElement('div');
                badge.className = 'qoj-precise-rating';
                badge.style.cssText = 'display:block; font-size:12px; font-weight:bold; margin-bottom:4px; line-height:1; font-family:monospace;';
                th.insertBefore(badge, th.firstChild);
            }

            badge.innerText = 4000;
            badge.style.color = getStyle(4000);
            badge.title = `评分: 4000(默认,无有效队伍)`;
        });
        return;
    }

    problemIndices.forEach((columnIdx, scoreIdx) => {
        let acCount = 0;

        validTeams.forEach(row => {
            const userInfo = row[2];
            const userId = userInfo[0];

            if (score[userId] && score[userId][scoreIdx]) {
                const problemData = score[userId][scoreIdx];
                if (problemData[0] > 0) {
                    acCount++;
                }
            }
        });

        // 计算评分
        let rating;
        if (acCount === 0) {
            // 没人过题,评分为 4000
            rating = 4000;
        } else {
            const acPercentage = (acCount / totalParticipants) * 100;
            const multiplier = getMultiplier(acPercentage);
            const estimatedTotal = acCount * multiplier;

            if (estimatedTotal <= 1) {
                rating = 4000;
            } else {
                rating = Math.round(RATING_CONFIG.BASE - RATING_CONFIG.K * Math.log10(estimatedTotal));
                rating = Math.max(800, Math.min(4000, rating));
            }
        }

        const th = headerRow.cells[columnIdx];
        let badge = th.querySelector('.qoj-precise-rating');

        if (!badge) {
            badge = document.createElement('div');
            badge.className = 'qoj-precise-rating';
            badge.style.cssText = 'display:block; font-size:12px; font-weight:bold; margin-bottom:4px; line-height:1; font-family:monospace;';
            th.insertBefore(badge, th.firstChild);
        }

        badge.innerText = rating;
        badge.style.color = getStyle(rating);

        // 当没人过题时,显示更明确的 tooltip
        if (acCount === 0) {
            badge.title = `AC 数: 0\n评分: 4000(默认,暂无人通过)\n参赛队: ${totalParticipants}`;
        } else {
            const acPercentage = (acCount / totalParticipants) * 100;
            const multiplier = getMultiplier(acPercentage);
            const estimatedTotal = acCount * multiplier;
            badge.title = `AC 数: ${acCount}\nAC 率: ${acPercentage.toFixed(1)}%\n补偿系数: ${multiplier.toFixed(2)}\n预测全场: ${estimatedTotal.toFixed(1)}\n参赛队: ${totalParticipants}`;
        }
    });
}

const GP30_SCORES = [100, 75, 60, 50, 45, 40, 36, 32, 29, 26, 24, 22, 20, 18, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1];

function getGP30(rank) {
    if (rank >= 1 && rank <= 30) return GP30_SCORES[rank - 1];
    return 0;
}

function getPerfColor(perf) {
    if (perf >= 270) return '#AA0000';
    if (perf >= 240) return '#FF3333';
    if (perf >= 210) return '#FF7777';
    if (perf >= 175) return '#FFBB55';
    if (perf >= 145) return '#FF88FF';
    if (perf >= 110) return '#AAAAFF';
    if (perf >= 70) return '#03A89E';
    return '#77FF77';
}

function calculatePerformance() {
    const table = document.querySelector('table');
    if (!table) return;

    let headerRow = table.querySelector('thead tr') || table.rows[0];
    if (!headerRow) return;

    if (typeof standings === 'undefined' || !Array.isArray(standings)) return;
    if (typeof score === 'undefined' || typeof score !== 'object') return;

    const problemIndices = getProblemIndices();
    if (problemIndices.length === 0) return;

    // 移除已有的 Perf 列(支持重复调用)
    if (headerRow.querySelector('.qoj-perf-header')) {
        headerRow.querySelector('.qoj-perf-header').remove();
        table.querySelectorAll('.qoj-perf-cell').forEach(td => td.remove());
    }

    const filteredStandings = standings.filter(row => {
        if (!Array.isArray(row) || row.length < 3) return false;
        const userInfo = row[2];
        if (!userInfo || !Array.isArray(userInfo)) return false;
        return userInfo[0].startsWith('ucup-team');
    });

    // n_teams:在过滤后的队伍中,过了至少一题的队伍数
    function solvedAtLeastOne(row) {
        const userId = row[2][0];
        for (let i = 0; i < problemIndices.length; i++) {
            if (score[userId]?.[i]?.[0] > 0) return true;
        }
        return false;
    }

    const teamsWithSolves = filteredStandings.filter(solvedAtLeastOne);
    const nTeams = teamsWithSolves.length;

    // 构建 userId -> performance 映射(standings 已按名次排序)
    const perfMap = {};
    teamsWithSolves.forEach((row, idx) => {
        const userId = row[2][0];
        const rank = idx + 1;
        const gp30 = getGP30(rank);
        const perf = nTeams > 0 ? 200 * (nTeams - rank + 1) / nTeams + gp30 : gp30;
        perfMap[userId] = perf;
    });

    // 添加表头
    const perfTh = document.createElement('th');
    perfTh.className = 'qoj-perf-header';
    perfTh.textContent = 'Perf';
    perfTh.style.cssText = 'font-size:12px; white-space:nowrap; text-align:center;';
    perfTh.title = `Performance = 200 × (n_teams − rank + 1) / n_teams + GP30\nn_teams: 至少过一题的队伍数`;
    headerRow.appendChild(perfTh);

    // 为 tbody 每一行添加单元格
    // standings 与 tbody 行一一对应
    const tbodyRows = table.querySelectorAll('tbody tr');
    standings.forEach((row, idx) => {
        if (!Array.isArray(row) || row.length < 3) return;
        const userInfo = row[2];
        if (!userInfo || !Array.isArray(userInfo)) return;
        const userId = userInfo[0];

        const tr = tbodyRows[idx - 100 * (getPageId() - 1)]; // 根据当前页码调整索引
        if (!tr) return;

        const td = document.createElement('td');
        td.className = 'qoj-perf-cell';
        td.style.cssText = 'text-align:center; font-weight:bold; font-family:monospace;';

        if (perfMap[userId] !== undefined) {
            const perf = perfMap[userId];
            td.textContent = perf % 1 === 0 ? perf.toFixed(0) : perf.toFixed(1);
            td.style.color = getPerfColor(perf);
            const rank = teamsWithSolves.indexOf(row) + 1;
            const gp30 = getGP30(rank);
            td.title = `Rank: ${rank}\nGP30: ${gp30}\nn_teams: ${nTeams}\nPerf: ${perf.toFixed(2)}`;
        } else {
            td.textContent = '—';
            td.style.color = '#999';
            td.title = '未过题,不计入 Performance';
        }
        tr.appendChild(td);
    });
}

function addToggleButton() {
    if (document.getElementById('toggle-ucup-teams')) return;

    const navbarUser = document.querySelector('.nav-link.dropdown-toggle');
    if (!navbarUser) return;

    const parentUl = navbarUser.closest('ul.navbar-nav, ul.nav');
    if (!parentUl) return;

    const li = document.createElement('li');
    li.style.listStyle = 'none';
    li.style.marginLeft = '10px';

    const button = document.createElement('button');
    button.id = 'toggle-ucup-teams';
    button.type = 'button';
    button.textContent = 'Only UCUP Teams';
    button.style.cssText = 'padding: 5px 10px; background-color: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.9em;';

    button.onmouseover = () => (button.style.backgroundColor = '#218838');
    button.onmouseout = () => (button.style.backgroundColor = '#28a745');

    button.onclick = function (e) {
        e.preventDefault();
        e.stopPropagation();
        window.onlyUCUPTeams = !window.onlyUCUPTeams;
        button.textContent = window.onlyUCUPTeams ? 'Show All Teams' : 'Only UCUP Teams';
        calculateRatings();
        calculatePerformance();
        return false;
    };

    li.appendChild(button);
    parentUl.insertBefore(li, navbarUser.parentElement);
}

function isStandingsPage() {
    return /\/contest\/\d+\/(standings|standings\/external)/.test(location.pathname);
}

function getPageId() {
    const pagination = document.querySelector('ul.pagination');
    if (!pagination) return 1;
    const active = pagination.querySelector('li.page-item.active a.page-link');
    if (!active) return 1;
    const match = active.textContent.trim().match(/^(\d+)$/);
    return match[1] || '1';
}

// ========== 其他功能 ==========

function backProblem() {
    if (document.querySelector('.nav-link.back-problem')) return;
    const nav = document.querySelector("ul.nav.nav-tabs");
    if (!nav) return;

    const match = location.pathname.match(/^\/contest\/(\d+)\/problem\/(\d+)/);
    if (!match) return;
    const pid = match[2];

    const li = document.createElement("li");
    li.className = "nav-item";
    li.innerHTML = `<a class="nav-link back-problem" href="/problem/${pid}" role="tab">Back to the problem</a>`;

    const backToContest = Array.from(nav.querySelectorAll("a")).find(a => a.textContent.includes("Back to the contest"));
    if (backToContest) backToContest.parentElement.before(li);
    else nav.appendChild(li);
}

function viewSubmissions() {
    if (document.querySelector('.nav-link.view-submissions')) return;
    const nav = document.querySelector('ul.nav.nav-tabs[role="tablist"]');
    if (!nav) return;

    const matchContest = location.pathname.match(/\/contest\/(\d+)\/problem\/(\d+)/);
    const matchProblem = location.pathname.match(/\/problem\/(\d+)/);
    const pid = matchContest ? matchContest[2] : matchProblem ? matchProblem[1] : null;
    if (!pid) return;

    const userLink = document.querySelector('a.dropdown-item[href*="/user/profile/"]');
    const username = userLink ? userLink.href.match(/profile\/([^/?#]+)/)?.[1] : null;
    if (!username) return;

    const li = document.createElement('li');
    li.className = 'nav-item';
    li.innerHTML = `<a class="nav-link view-submissions" href="/submissions?problem_id=${pid}&submitter=${username}" role="tab">View submissions</a>`;
    nav.appendChild(li);
}

function viewInContestLinks() {
    const alertBox = document.querySelector('.alert.alert-primary');
    if (!alertBox) return;

    const listItems = alertBox.querySelectorAll('ul.uoj-list li a[href*="/contest/"]');
    if (!listItems.length) return;

    const pidMatch = window.location.pathname.match(/\/problem\/(\d+)/);
    if (!pidMatch) return;
    const pid = pidMatch[1];

    listItems.forEach(a => {
        if (a.parentElement.querySelector('a[data-added="true"]')) return;
        const match = a.href.match(/\/contest\/(\d+)(\?v=\d+)?/);
        if (!match) return;
        const cid = match[1];
        const ver = match[2] || '';
        const viewLink = document.createElement('a');
        viewLink.textContent = '[view in contest]';
        viewLink.href = `/contest/${cid}/problem/${pid}${ver}`;
        viewLink.style.marginLeft = '4px';
        viewLink.dataset.added = 'true';
        a.insertAdjacentElement('afterend', viewLink);
    });
}

function addAcTag() {
    if (window.__qoj_fullscore_lock) return;
    window.__qoj_fullscore_lock = true;
    if (window.__qoj_no_ac) return;
    const pid = getProblemId();
    const username = getUsername();
    if (!pid || !username) return;
    try {
        const pid = getProblemId();
        const username = getUsername();
        if (!pid || !username) return;

        const infoRow = document.querySelector('.row.d-flex.justify-content-center');
        if (!infoRow) return;
        if (infoRow.querySelector('.badge-fullscore')) return;

        const totalEl = [...infoRow.querySelectorAll('.badge.badge-secondary')]
            .find(e => e.textContent.includes('Total points'));
        if (!totalEl) return;

        const total = parseFloat(totalEl.textContent.replace(/[^\d.]/g, ''));
        if (isNaN(total)) return;

        fetch(`/submissions?problem_id=${pid}&submitter=${username}&min_score=${total}&max_score=${total}`)
            .then(res => res.text())
            .then(html => {
                const match = html.match(/<td><a href="(\/submission\/\d+)">/);
                if (!match) {
                    window.__qoj_no_ac = true;
                    return;
                }
                const sub = match[1];
                const badge = document.createElement('a');
                badge.className = 'badge badge-success mr-1 badge-fullscore';
                badge.textContent = 'Accepted ✓';
                badge.href = `${sub}`;
                badge.target = '_blank';
                infoRow.appendChild(badge);
                const submitLink = document.querySelector('a.nav-link[href="#tab-submit-answer"]');
                if (!submitLink) return;

                if (submitLink.classList.contains('submit-green')) return;

                submitLink.classList.add('submit-green');

                const style = document.createElement('style');
                style.textContent = `
                        a.nav-link.submit-green {
                            color: #00cc00 !important;
                        }
                        a.nav-link.submit-green:hover {
                            color: #00cc00 !important;
                        }
                    `;
                document.head.appendChild(style);
            })
            .catch(err => console.error('检测满分失败:', err))
            .finally(() => {
                setTimeout(() => { window.__qoj_fullscore_lock = false; }, 100);
            });
    } catch (e) {
        console.error(e);
        window.__qoj_fullscore_lock = false;
    }
}

(function () {
    'use strict';
    // --- 定义主函数 ---
    function main() {
        switchDomain();

        // standings 页面特有功能
        if (isStandingsPage()) {
            addToggleButton();
            // 延迟执行,等待表格渲染完成
            setTimeout(calculateRatings, 500);
        }

        // 其他功能
        backProblem();
        viewSubmissions();
        viewInContestLinks();
        addAcTag();
    }

    // --- 初次执行 ---
    main();

    // --- 使用 MutationObserver 监听 DOM 动态变化 ---
    const observer = new MutationObserver(() => {
        // 检查关键元素是否存在
        const needRun =
            document.querySelector('.alert.alert-primary') || // 可能是 viewInContestLinks 所需
            document.querySelector('ul.nav.nav-tabs') || // viewSubmissions / backProblem
            document.querySelector('.nav-link.dropdown-toggle') || // 登录状态
            document.querySelector('.nav.nav-pills.float-right'); // 游客状态

        if (needRun) {
            observer.disconnect(); // 先断开,防止重复触发
            setTimeout(() => {
                main(); // 稍延迟再执行,确保元素已稳定渲染

                // standings 页面需要额外延迟等待表格渲染
                if (isStandingsPage()) {
                    setTimeout(calculateRatings, 500);
                    setTimeout(calculatePerformance, 500);
                }

                observer.observe(document.body, { childList: true, subtree: true }); // 重新监听
            }, 100);
        }
    });

    // 启动观察器
    observer.observe(document.body, { childList: true, subtree: true });
})();