atcoder-wait-time-display

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
})();