AtCoder Heuristic Duration Labels

コンテスト成績表のHeuristicタブで長期・短期を区別できるようにする

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         AtCoder Heuristic Duration Labels
// @namespace    https://github.com/isee9129/
// @version      1.0.0
// @description  コンテスト成績表のHeuristicタブで長期・短期を区別できるようにする
// @author       isee
// @match        https://atcoder.jp/users/*/history?contestType=heuristic
// @match        https://atcoder.jp/users/*/history?contestType=heuristic&lang=*
// @grant        none
// @copyright    2025, isee (https://github.com/isee9129/)
// @license      MIT License; https://opensource.org/licenses/MIT
// ==/UserScript==

(function() {
    'use strict';

    // =======================
    // 設定項目
    // =======================

    // マーク・背景色のON OFF
    const ENABLE_MARK = true;
    const ENABLE_BACKGROUND_S = false;
    const ENABLE_BACKGROUND_L = true;

    // 短期・長期のボーダー(秒)
    const DURATION_BORDER = 86400;

    // コンテスト名の前につけるマーク 'Ⓢ', 'Ⓛ', '🅢', '🅛' など
    const SYMBOL_S = 'Ⓢ';
    const SYMBOL_L = '🅛';

    // 背景色 '#fbeefb'など
    const COLOR_S = '#eefbee';
    const COLOR_L = '#fbeefb';

    // =======================
    // 本体
    // =======================

    // コンテストIDを取得し、短期・長期に分ける
    async function fetchContestIDs() {
        function sleep(ms) {
            return new Promise(resolve => setTimeout(resolve, ms));
        }

        // 過剰にアクセスするのを避ける
        await sleep(1000);

        const url = 'https://kenkoooo.com/atcoder/resources/contests.json';
        const res = await fetch(url, { cache: 'no-store' });
        const data = await res.json();

        const S_ID = [];
        const L_ID = [];

        for (const item of data) {
            if (!item.id || typeof item.duration_second !== 'number') continue;
            if (item.duration_second <= DURATION_BORDER) {
                S_ID.push(item.id);
            } else {
                L_ID.push(item.id);
            }
        }

        return { S_ID, L_ID };
    }

    let sSet = new Set();
    let lSet = new Set();

    const MODE_NONE = 0
    const MODE_S = 1;
    const MODE_L = 2;

    function parseDatetime(text) {
        const cleaned = text.replace(/\(.+?\)/, '').replace(/\s+/, ' ');
        return cleaned.replace(' ', 'T');
    }

    function isWeekendSatSun(d) {
        const w = d.getDay();
        return w === 0 || w === 6;
    }

    function isMonday(d) {
        return d.getDay() === 1;
    }

    function getContestId(td) {
        const link = td.querySelector('a[href*="/contests/"]');
        if (!link) return null;
        const href = link.getAttribute('href');
        const m = href.match(/\/contests\/([^\/]+)/);
        return m ? m[1] : null;
    }

    function process() {
        const rows = document.querySelectorAll('#history tbody tr');

        for (const row of rows) {
            const timeEl = row.querySelector('time.fixtime-full');
            if (!timeEl) continue;

            const contestTd = row.children[1];
            if (!contestTd) continue;

            const contestId = getContestId(contestTd);
            if (!contestId) continue;

            const original = timeEl.textContent.trim();
            const iso = parseDatetime(original);
            const dateObj = new Date(iso);

            let mode = MODE_NONE;
            const yyyy = dateObj.getFullYear();
            const hh = dateObj.getHours();
            const mm = dateObj.getMinutes();

            // 長短判定
            if (sSet.has(contestId)) {
                mode = MODE_S;
            } else if (lSet.has(contestId)) {
                mode = MODE_L;
            } else if (yyyy >= 2024) {
                // データにないものは、土日の 19:00 or 23:00 終了なら短期、月曜の 19:00 終了なら長期とする
                if (isWeekendSatSun(dateObj) && (hh === 19 || hh === 23) && mm === 0) {
                    mode = MODE_S;
                } else if (isMonday(dateObj) && hh === 19 && mm === 0) {
                    mode = MODE_L;
                }
            }

            let mark = '';
            let bgcolor = '';

            if (mode === MODE_S) {
                mark = SYMBOL_S;
                bgcolor = COLOR_S;
            } else if (mode === MODE_L) {
                mark = SYMBOL_L;
                bgcolor = COLOR_L;
            }

            // マークを追加
            if (ENABLE_MARK && mode !== MODE_NONE) {
                if (!contestTd.dataset.lsAdded) {
                    const first = contestTd.firstChild;
                    contestTd.insertBefore(document.createTextNode(mark + ' '), first);
                    contestTd.dataset.lsAdded = "1";
                }
            }

            // 背景色を変更
            if (mode === MODE_L && ENABLE_BACKGROUND_L || mode === MODE_S && ENABLE_BACKGROUND_S) {
                row.style.backgroundColor = bgcolor;
            }
        }
    }

    async function main() {
        const { S_ID, L_ID } = await fetchContestIDs();
        sSet = new Set(S_ID);
        lSet = new Set(L_ID);

        process();
    }

    main();
})();