atcoder-wait-time-display

AtCoder の提出待ち時間を表示します.

// ==UserScript==
// @name         atcoder-wait-time-display
// @namespace    iilj
// @version      2021.8.2
// @description  AtCoder の提出待ち時間を表示します.
// @author       iilj
// @license      MIT
// @supportURL   https://github.com/iilj/atcoder-wait-time-display/issues
// @match        https://atcoder.jp/contests/*/tasks/*
// @grant        GM_addStyle
// ==/UserScript==
const pad = (num, length = 2) => `00${num}`.slice(-length);
const formatTime = (hours, minutes, seconds) => {
    return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
};
const secondsToString = (diffWholeSecs) => {
    const diffSecs = diffWholeSecs % 60;
    const diffMinutes = Math.floor(diffWholeSecs / 60) % 60;
    const diffHours = Math.floor(diffWholeSecs / 3600) % 24;
    const diffDate = Math.floor(diffWholeSecs / (3600 * 24));
    if (diffDate > 0)
        return `${diffDate}日`;
    return formatTime(diffHours, diffMinutes, diffSecs);
};

var css = "div#js-awtd-timer {\n  position: fixed;\n  right: 10px;\n  bottom: 80px;\n  width: 160px;\n  height: 80px;\n  margin: 0;\n  padding: 20px 0;\n  background-image: url(\"//img.atcoder.jp/assets/contest/digitalclock.png\");\n  text-align: center;\n  line-height: 20px;\n  font-size: 15px;\n  cursor: pointer;\n  z-index: 50;\n}\ndiv#js-awtd-timer .js-awtd-timer-top {\n  color: inherit;\n}\ndiv#js-awtd-timer .js-awtd-timer-bottom {\n  color: #cc0000;\n}\n\np#fixed-server-timer {\n  box-sizing: border-box;\n}";

class Timer {
    constructor(lastSubmitTime, submitIntervalSecs) {
        this.lastSubmitTime = lastSubmitTime;
        this.submitIntervalSecs = submitIntervalSecs;
        GM_addStyle(css);
        this.element = document.createElement('div');
        this.element.id = Timer.ELEMENT_ID;
        this.element.title = `間隔:${this.submitIntervalSecs} 秒`;
        document.body.appendChild(this.element);
        this.top = document.createElement('div');
        this.top.classList.add('js-awtd-timer-top');
        this.element.appendChild(this.top);
        this.bottom = document.createElement('div');
        this.bottom.classList.add('js-awtd-timer-bottom');
        this.element.appendChild(this.bottom);
        this.prevSeconds = -1;
        this.intervalID = window.setInterval(() => {
            this.updateTime();
        }, 100);
        this.displayInterval = false;
        this.element.addEventListener('click', () => {
            this.displayInterval = !this.displayInterval;
            this.prevSeconds = -1;
            this.updateTime();
        });
    }
    updateTime() {
        const currentTime = moment();
        const seconds = currentTime.seconds();
        if (seconds === this.prevSeconds)
            return;
        if (this.displayInterval) {
            this.top.textContent = '提出間隔';
            this.bottom.textContent = `${this.submitIntervalSecs} 秒`;
        }
        else {
            if (this.lastSubmitTime !== null) {
                // 経過時間を表示
                const elapsedMilliseconds = currentTime.diff(this.lastSubmitTime);
                const elapsedSeconds = Math.floor(elapsedMilliseconds / 1000);
                this.top.textContent = `経過:${secondsToString(elapsedSeconds)}`;
                const waitTime = Math.max(0, this.submitIntervalSecs - elapsedSeconds);
                this.bottom.textContent = `待ち:${secondsToString(waitTime)}`;
                // if (waitTime > 0) this.bottom.style.color = '#cc0000';
                // else this.bottom.style.color = 'inherit';
            }
            else {
                this.top.textContent = 'この問題は';
                this.bottom.textContent = '未提出です';
            }
        }
    }
}
Timer.ELEMENT_ID = 'js-awtd-timer';

const extractContestAndProblemSlugs = (url) => {
    // https://atcoder.jp/contests/*/tasks/*
    const urlMatchArray = /^https?:\/\/atcoder\.jp\/contests\/([^/]+)\/tasks\/([^/]+)/.exec(url);
    if (urlMatchArray === null) {
        throw new Error('url が不正です');
    }
    return [urlMatchArray[1], urlMatchArray[2]];
};

const getRecentSubmissions = async (contestSlug, taskSlug) => {
    const res = await fetch(`https://atcoder.jp/contests/${contestSlug}/submissions/me?f.Task=${taskSlug}`);
    const text = await res.text();
    const dom = new DOMParser().parseFromString(text, 'text/html');
    // console.log(dom);
    // 2021-05-29 16:15:34+0900
    const rows = dom.querySelectorAll('#main-container div.panel.panel-default.panel-submission > div.table-responsive > table > tbody > tr');
    const ret = [];
    rows.forEach((row) => {
        var _a;
        const problem = row.querySelector(`a[href^="/contests/${contestSlug}/tasks/${taskSlug}"]`);
        if (problem === null) {
            throw new Error('テーブルに提出先不明の行があります');
        }
        const time = row.querySelector('time.fixtime-second');
        if (time === null) {
            throw new Error('テーブルに提出時刻不明の行があります');
        }
        const [contestSlugTmp, taskSlugTmp] = extractContestAndProblemSlugs(problem.href);
        if (contestSlugTmp !== contestSlug || taskSlugTmp !== taskSlug) {
            throw new Error('異なる問題への提出記録が紛れています');
        }
        const submission = row.querySelector(`a[href^="/contests/${contestSlug}/submissions/"]`);
        if (submission === null) {
            throw new Error('テーブルに提出 ID 不明の行があります');
        }
        const statusLabel = row.querySelector('span.label');
        if (statusLabel === null) {
            throw new Error('提出ステータス不明の行があります');
        }
        const label = (_a = statusLabel.textContent) === null || _a === void 0 ? void 0 : _a.trim();
        if (label === undefined) {
            throw new Error('提出ステータスが空の行があります');
        }
        const submitTime = moment(time.innerText);
        ret.push([submission.href, label, submitTime]);
    });
    return ret;
};
const getSubmitIntervalSecs = async (contestSlug) => {
    var _a;
    const res = await fetch(`https://atcoder.jp/contests/${contestSlug}?lang=ja`);
    const text = await res.text();
    const dom = new DOMParser().parseFromString(text, 'text/html');
    // 例外的な処理
    if (contestSlug === 'wn2017_1') {
        return 3600;
    }
    else if (contestSlug === 'caddi2019') {
        return 300;
    }
    // AHC/HTTF/日本橋ハーフマラソン/Future 仕様の文字列を検索
    const candidates = dom.getElementsByTagName('strong');
    for (let i = 0; i < candidates.length; ++i) {
        const content = (_a = candidates[i].textContent) === null || _a === void 0 ? void 0 : _a.trim();
        if (content === undefined)
            continue;
        // 5分以上の間隔
        const matchArray = /^(\d+)(秒|分|時間)以上の間隔/.exec(content);
        if (matchArray === null)
            continue;
        if (matchArray[2] === '秒')
            return Number(matchArray[1]);
        if (matchArray[2] === '分')
            return Number(matchArray[1]) * 60;
        if (matchArray[2] === '時間')
            return Number(matchArray[1]) * 3600;
    }
    const statement = dom.getElementById('contest-statement');
    if (statement === null) {
        throw new Error('コンテスト説明文が見つかりませんでした');
    }
    const statementText = statement.textContent;
    if (statementText === null) {
        throw new Error('コンテスト説明文が空です');
    }
    // Asprova 仕様
    //   「提出間隔:プログラム提出後10分間は再提出できません。」
    //   「提出後1時間は再提出できません」
    // Hitachi Hokudai 仕様
    //   「提出直後の1時間は再提出することができません」
    //   「提出直後の1時間は、再提出することができません」
    // ヤマトコン仕様
    //   「提出後30分は再提出することはできません」
    {
        const matchArray = /提出[^\d]{1,5}(\d+)(秒|分|時間).{1,5}再提出/.exec(statementText);
        if (matchArray !== null) {
            if (matchArray[2] === '秒')
                return Number(matchArray[1]);
            if (matchArray[2] === '分')
                return Number(matchArray[1]) * 60;
            if (matchArray[2] === '時間')
                return Number(matchArray[1]) * 3600;
        }
    }
    // PAST 仕様
    //   「同じ問題に1分以内に再提出することはできません」
    {
        const matchArray = /(\d+)(秒|分|時間).{1,5}再提出.{0,10}できません/.exec(statementText);
        if (matchArray !== null) {
            if (matchArray[2] === '秒')
                return Number(matchArray[1]);
            if (matchArray[2] === '分')
                return Number(matchArray[1]) * 60;
            if (matchArray[2] === '時間')
                return Number(matchArray[1]) * 3600;
        }
    }
    // Chokudai Contest 仕様
    //   「CEの提出を除いて5分に1回しか提出できません」
    //   「前の提出から30秒以上開けての提出をお願いします」
    //   「前の提出から5分以上開けての提出をお願いします」
    // Introduction to Heuristics Contest 仕様
    //   「提出の間隔は5分以上空ける必要があります」
    {
        const matchArray = /提出[^\d]{1,5}(\d+)(秒|分|時間)以上(?:空け|開け)/.exec(statementText);
        // console.log(matchArray);
        if (matchArray !== null) {
            if (matchArray[2] === '秒')
                return Number(matchArray[1]);
            if (matchArray[2] === '分')
                return Number(matchArray[1]) * 60;
            if (matchArray[2] === '時間')
                return Number(matchArray[1]) * 3600;
        }
    }
    {
        const matchArray = /(\d+)(秒|分|時間)に1回.{1,5}提出/.exec(statementText);
        if (matchArray !== null) {
            if (matchArray[2] === '秒')
                return Number(matchArray[1]);
            if (matchArray[2] === '分')
                return Number(matchArray[1]) * 60;
            if (matchArray[2] === '時間')
                return Number(matchArray[1]) * 3600;
        }
    }
    // ゲノコン2021 仕様
    //   「提出時間の間隔は,8/28 21:00までは10分,8/28 21:00以降は2時間となります」
    {
        const matchArray = /提出[^\d]{1,5}間隔[^\d]+?(?:(\d+)\/(\d+) (\d\d):(\d\d)(まで|以降)は(\d+)(秒|分|時間)[,、,\s]?)+/.exec(statementText);
        // console.log(matchArray);
        if (matchArray !== null) {
            const re = /(\d+)\/(\d+) (\d+):(\d\d)(まで|以降)は(\d+)(秒|分|時間)/g;
            let matchArrayInner;
            const currentTime = moment();
            while ((matchArrayInner = re.exec(matchArray[0]))) {
                console.log(matchArrayInner);
                const momentInput = {
                    year: startTime.year(),
                    month: Number(matchArrayInner[1]) - 1,
                    days: Number(matchArrayInner[2]),
                    hours: Number(matchArrayInner[3]),
                    minutes: Number(matchArrayInner[4]),
                };
                const timeThreshold = moment(momentInput);
                if (matchArrayInner[5] === 'まで') {
                    if (currentTime.isBefore(timeThreshold)) {
                        if (matchArrayInner[7] === '秒')
                            return Number(matchArrayInner[6]);
                        if (matchArrayInner[7] === '分')
                            return Number(matchArrayInner[6]) * 60;
                        if (matchArrayInner[7] === '時間')
                            return Number(matchArrayInner[6]) * 3600;
                    }
                }
                else {
                    if (currentTime.isAfter(timeThreshold)) {
                        if (matchArrayInner[7] === '秒')
                            return Number(matchArrayInner[6]);
                        if (matchArrayInner[7] === '分')
                            return Number(matchArrayInner[6]) * 60;
                        if (matchArrayInner[7] === '時間')
                            return Number(matchArrayInner[6]) * 3600;
                    }
                }
            }
        }
    }
    {
        const matchArray = /提出[^\d]{1,5}間隔.+?(\d+)(秒|分|時間)/.exec(statementText);
        if (matchArray !== null) {
            if (matchArray[2] === '秒')
                return Number(matchArray[1]);
            if (matchArray[2] === '分')
                return Number(matchArray[1]) * 60;
            if (matchArray[2] === '時間')
                return Number(matchArray[1]) * 3600;
        }
    }
    return 5;
};

void (async () => {
    // 終了後のコンテストに対しては処理しない?
    //if (moment() >= endTime) return;
    const [contestSlug, taskSlug] = extractContestAndProblemSlugs(document.location.href);
    if (contestSlug !== contestScreenName) {
        throw new Error('url が不正です');
    }
    const submitIntervalSecs = await getSubmitIntervalSecs(contestSlug);
    const recentSubmissions = await getRecentSubmissions(contestSlug, taskSlug);
    const lastSubmitTime = recentSubmissions.reduce((prev, [, statusLabel, submitTime]) => {
        if (statusLabel === 'CE')
            return prev;
        if (prev === null || submitTime > prev)
            return submitTime;
        return prev;
    }, null);
    new Timer(lastSubmitTime, submitIntervalSecs);
})();