AtCoder Difficulty Display

AtCoder Problemsの難易度を表示します。

Versão de: 02/08/2020. Veja: a última versão.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name            AtCoder Difficulty Display
// @namespace       https://github.com/hotarunx
// @homepage        https://github.com/hotarunx/AtCoderDifficultyDisplay
// @supportURL      https://github.com/hotarunx/AtCoderDifficultyDisplay/issues
// @version         1.0.0
// @description     AtCoder Problemsの難易度を表示します。
// @description:en  display a difficulty of AtCoder Problems.
// @author          hotarunx
// @match           https://atcoder.jp/contests/*/tasks*
// @match           https://atcoder.jp/contests/*/submissions*
// @grant           none
// @connect         https://kenkoooo.com/atcoder/resources/*
// @connect         https://kenkoooo.com/atcoder/atcoder-api/*
// @license         MIT
//
// Copyright(c) 2020 hotarunx
// This software is released under the MIT License, see LICENSE or https://github.com/hotarunx/AtCoderMyExtensions/blob/master/LICENSE.
//
// ==/UserScript==

// 現在時間、コンテスト開始時間、コンテスト終了時間(UNIX時間 + 時差)
const nowTime = Math.floor(Date.now() / 1000);
const contestStartTime = Math.floor(Date.parse(startTime._i) / 1000);
const contestEndTime = Math.floor(Date.parse(endTime._i) / 1000);

(async function () {
    // URLから問題ID(ex: abc170_a)を取得
    const path = location.pathname.split("/");
    const problemId = path[path.length - 1];
    const isABS = path[2] == "abs";

    // 問題のコンテストが開催中ならば全ての処理をスキップする。
    if (!isABS && !isContestOver(nowTime)) return;

    const diffURL = "https://kenkoooo.com/atcoder/resources/problem-models.json";
    const diffKey = "atcoderDifficultyDisplayEstimatedDifficulties";

    const submissionsURL = "https://kenkoooo.com/atcoder/atcoder-api/results?user=" + userScreenName;
    const submissionsKey = "atcoderDifficultyDisplayUserSubmissions";

    const estimatedDifficulties = await fetchAPIData(diffURL, diffKey, 1 * 60 * 60);
    const userSubmissions = await fetchAPIData(submissionsURL, submissionsKey, 1 * 60 * 60);


    if (path[path.length - 2] == "tasks") {
        const problemStatus = getElementOfProblemStatus();
        const problemTitle = document.getElementsByClassName("h2")[0];

        changeProblemTitle(problemId, estimatedDifficulties, problemTitle);
        addDifficultyText(problemId, estimatedDifficulties, problemStatus);
        if (!isABS)
            addIsSolvedText(problemId, userSubmissions, problemStatus);
    }

    const as = document.getElementsByTagName("a");
    for (const item of as) {
        if (item.text.length <= 2) continue;
        const h = item.getAttribute("href");
        if (typeof (h) != "string") continue;
        const hpath = h.split("/");

        if (hpath[hpath.length - 2] == "tasks") {
            const hProblemId = hpath[hpath.length - 1];
            changeProblemTitle(hProblemId, estimatedDifficulties, item, 12);
        }

    }
})();

// コンテストが終了した?
function isContestOver(time) {
    // 緩衝時間(20分)
    const bufferTime = 20 * 60;

    // 現在時間 > コンテスト終了時間 + 緩衝時間?
    if (time > contestEndTime + bufferTime) return true;
    return false;
}

// APIサーバからデータを取得してlocalStorageに保存して返す
// 直前の取得から時間が経過していないならば保存したデータを返す
async function fetchAPIData(url, keyData, timeInterval) {
    const keyLastFetch = keyData + "lastFetchedAt";
    let jsondata = JSON.parse(localStorage.getItem(keyData));
    const fetchTime = parseInt(localStorage.getItem(keyLastFetch));

    // コンテストが終了していないならばデータ取得はしない
    if (!isContestOver(nowTime)) return jsondata;

    // 次のいずれかを満たすならば取得する
    // * データが保存されていない
    // * 直前の取得からtimeInterval経過した
    // * 直前の取得時にコンテストが終了していなかった

    let need2Fetch = false;
    if (isNaN(fetchTime)) need2Fetch = true;
    else if (nowTime >= timeInterval + fetchTime) need2Fetch = true;
    else if (!isContestOver(fetchTime)) need2Fetch = true;

    // データを取得する
    if (need2Fetch) {
        // alert(keyData + "is fetched.");
        jsondata = await (await (fetch(url))).json();
        removeUnusedValues(jsondata);
        localStorage.setItem(keyData, JSON.stringify(jsondata));
        localStorage.setItem(keyLastFetch, nowTime);
    }

    return jsondata;
}

function removeUnusedValues(jsondata) {
    const necessaryKeys = ["difficulty", "is_experimental", "epoch_second", "point", "result", "problem_id"];

    for (const item in jsondata) {
        for (const key in jsondata[item]) {
            if (!necessaryKeys.includes(key)) {
                delete jsondata[item][key];
            }
        }
    }
}

// Webページの問題ステータス(実行時間制限とメモリ制限が書かれた部分)のHTMLオブジェクトを取得
function getElementOfProblemStatus() {
    let element_status;

    const main_container = document.getElementById('main-container');
    const elements_p = main_container.getElementsByTagName("p");

    for (let i = 0; i < elements_p.length; i++) {
        const element = elements_p[i];
        if (element.textContent.match("メモリ制限:") || element.textContent.match("Memory Limit:")) {
            element_status = element;
            break;
        }
    }

    return element_status;
}

// レーティングに対応する色のカラーコード
function colorRating(rating) {
    if (rating < 400) return '#808080'; //          gray
    else if (rating < 800) return '#804000'; //     brown
    else if (rating < 1200) return '#008000'; //    green
    else if (rating < 1600) return '#00C0C0'; //    cyan
    else if (rating < 2000) return '#0000FF'; //    blue
    else if (rating < 2400) return '#C0C000'; //    yellow
    else if (rating < 2800) return '#FF8000'; //    orange
    return '#FF0000'; //                            red
}

// レートを表す難易度円(◒)を生成
function generateDifficultyCircle(rating, size = 12) {
    if (rating < 3200) {
        // 色と円がどのぐらい満ちているかを計算
        const color = colorRating(rating);
        const percentFull = (rating % 400) / 400 * 100;

        // ◒を生成
        return "<span style = 'display: inline-block; border-radius: 50%; border-style: solid;border-width: 1px; margin-right: 5px; vertical-align: initial; height: " + size + "px; width: " + size + "px;border-color: " + color + "; background: linear-gradient(to top, " + color + " 0%, " + color + " " + percentFull + "%, rgba(0, 0, 0, 0) " + percentFull + "%, rgba(0, 0, 0, 0) 100%); '></span>";

    }
    // 金銀銅は例外処理
    else if (rating < 3600) {
        return '<span style="display: inline-block; border-radius: 50%; border-style: solid;border-width: 1px; margin-right: 5px; vertical-align: initial; height: ' + size + 'px; width: ' + size + 'px; border-color: rgb(150, 92, 44); background: linear-gradient(to right, rgb(150, 92, 44), rgb(255, 218, 189), rgb(150, 92, 44));"></span>';

    } else if (rating < 4000) {
        return '<span style="display: inline-block; border-radius: 50%; border-style: solid;border-width: 1px; margin-right: 5px; vertical-align: initial; height: ' + size + 'px; width: ' + size + 'px; border-color: rgb(128, 128, 128); background: linear-gradient(to right, rgb(128, 128, 128), white, rgb(128, 128, 128));"></span>';

    } else {
        return '<span style="display: inline-block; border-radius: 50%; border-style: solid;border-width: 1px; margin-right: 5px; vertical-align: initial; height: ' + size + 'px; width: ' + size + 'px; border-color: rgb(255, 215, 0); background: linear-gradient(to right, rgb(255, 215, 0), white, rgb(255, 215, 0));"></span>';

    }
}

// 400未満のレーティングを補正
// 参考 https://qiita.com/anqooqie/items/92005e337a0d2569bdbd#性質4-初心者への慈悲
function correctLowerRating(rating) {
    if (rating >= 400) return rating;
    do {
        rating = 400 / Math.exp((400 - rating) / 400);
    } while (rating < 0);
    return rating;
}

function changeProblemTitle(problemId, estimatedDifficulties, problemTitle, size = 30) {
    const problem = estimatedDifficulties[problemId];

    // 問題が存在しなければ終了
    if (problem == null) return;
    if (problem.difficulty != null) {
        const difficulty = correctLowerRating(problem.difficulty).toFixed();
        problemTitle.style.color = colorRating(difficulty);
        if (problem.is_experimental) problemTitle.insertAdjacentHTML("afterbegin", "🧪");
        problemTitle.insertAdjacentHTML("beforebegin", generateDifficultyCircle(difficulty, size));
    }
    else {
        problemTitle.style.color = "#17a2b8";
        const unavailableCircle = "<span style='font-weight: bold; color: white;background:#17a2b8; border-radius: 50%;padding:0.2rem;'>?</span>";
        problemTitle.insertAdjacentHTML("afterbegin", unavailableCircle);
    }
}

// 推定難易度文字列を生成
function generateDifficultyText(difficulty, is_experimental) {
    // 推定難易度を補正して四捨五入
    difficulty = correctLowerRating(difficulty);
    difficulty = difficulty.toFixed();

    const textValue = "<span style='font-weight: bold; color: " + colorRating(difficulty) + ";'>" + difficulty + "</span>";
    // textDiff = "<a href='https://kenkoooo.com/atcoder/#/table/" + userScreenName + "'>Difficulty</a>";
    const textDiff = "Difficulty";

    return textDiff + ": " + textValue + (is_experimental ? " (🧪)" : "");
}

// 推定難易度表示を追加する
function addDifficultyText(problemId, estimatedDifficulties, problemStatus) {
    const problem = estimatedDifficulties[problemId];

    // 問題が存在しなければ終了
    if (problem == null) return;

    if (problem.difficulty != null) {
        // 難易度を表示する文字列を生成
        const text = generateDifficultyText(problem.difficulty, problem.is_experimental);
        problemStatus.insertAdjacentHTML('beforeend', " / " + text);
    } else
        problemStatus.insertAdjacentHTML('beforeend', " / Difficulty: <span style='font-weight: bold; color: #17a2b8;'>Unavailable</span>");
}

// AC、コンテスト中AC、ペナルティ数、AC時間、最大得点、コンテスト中最大得点を計算
function searchSubmissionsResult(submissions) {
    const nonPenaltyJudge = ["AC", "CE", "IE", "WJ", "WR"];
    submissions.sort((a, b) => a.epoch_second - b.epoch_second);

    let accepted = false;
    let acceptedDuringContest = false;
    let penalties = 0;
    let acceptedTime = contestEndTime;
    let maxPoint = 0;
    let maxPointDuringContest = 0;

    for (const item of submissions) {
        const duringContest = item.epoch_second <= contestEndTime;

        if (item.result == "AC") {
            accepted = true;
            if (duringContest) {
                acceptedDuringContest = true;
                acceptedTime = Math.min(item.epoch_second, acceptedTime);
            }
        }

        if (!accepted && duringContest && !nonPenaltyJudge.includes(item.result)) {
            penalties++;
        }

        maxPoint = Math.max(item.point, maxPoint);
        if (duringContest)
            maxPointDuringContest = Math.max(item.point, maxPointDuringContest);
    }

    return { accepted, acceptedDuringContest, penalties, acceptedTime, maxPoint, maxPointDuringContest };
}

function epochTime2HHMM(time) {
    return Math.floor(time / 60).toFixed() + ":" + (time % 60).toFixed().padStart(2, '0');
}

// ACしたか、AC時間、ペナルティ数を表示
function addIsSolvedText(problemId, userSubmissions, problemStatus) {
    const submissions = userSubmissions.filter(function (item, index) { if (item.problem_id == problemId) return true; });
    const submitted = submissions.length > 0;
    const { accepted, acceptedDuringContest, penalties, acceptedTime, maxPoint, maxPointDuringContest } = searchSubmissionsResult(submissions);

    let text = "Is Solved: ";
    if (acceptedDuringContest) text += "<span style='font-weight: bold; color: white; background: green; border-radius: 20%;'>✓</span>";
    else if (accepted) text += "<span style='font-weight: bold; color: green;'>✓</span>";
    else if (submitted) text += "<span style='font-weight: bold; color: orange;'>✘</span>";
    else text += "<span style='font-weight: bold; color: gray;'>✘</span>";

    if (acceptedDuringContest)
        text += " <span style='font-size: x-small; color: grey;'>" + epochTime2HHMM(acceptedTime - contestStartTime) + "</span> ";

    if (penalties > 0)
        text += " <span style='font-size: x-small; color: red;'>(" + penalties + ")</span> ";

    if (maxPoint >= 10000) {
        text += " <span style='font-size: x-small; color: orange;'>" + maxPoint + "</span> ";
        if (maxPointDuringContest != maxPoint)
            text += " <span style='font-size: x-small; color: orange;'>(" + maxPointDuringContest + ")</span> ";
    }

    problemStatus.insertAdjacentHTML('beforeend', " / " + text);
}