AtCoderResultNotifier

Send submission result notifications on AtCoder. You MUST install AtCoderResultNotifier_WJCollecter too.

Versão de: 15/08/2018. 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         AtCoderResultNotifier
// @namespace    https://satanic0258.github.io/
// @version      1.0.0
// @description  Send submission result notifications on AtCoder. You MUST install AtCoderResultNotifier_WJCollecter too.
// @author       satanic0258
// @include      https://beta.atcoder.jp/*
// @grant        none
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js
// ==/UserScript==

/*jshint esversion: 6 */

$(function() {
    'use strict';

    // 読み込み元と整合性を取る
    // https://wiki.greasespot.net/Third-Party_Libraries
    this.$ = this.jQuery = jQuery.noConflict(true);

    // localStorageに保存するキー
    const storageKey_WJ = 'AtCoderResultNotifier_WJ';
    const storageKey_lastFetchedAt = 'AtCoderResultNotifier_lastFetchedAt';

    // リクエスト間隔(デフォルトは公式と同じ3500ms)
    const interval = 3500;
    let timer = null;

    // 充分大きい値 (充分大きい時刻として使用)
    const INF = (Number.MAX_SAFE_INTEGER - 1) / 2;

    // 最終通知時刻 (コンテナ初期化に使用)
    let lastNotifiedAt = null;

    // このタブで最後にアクティブだった時刻 (非アクティブ時の動作判定で使用)
    let lastFocusedAt = INF;

    // ジャッジステータスを大別
    function classifyStatus(status){ // => ['default', 'success', 'warning']
        if(status === 'WJ') return 'default';
        if(status === 'AC') return 'success';
        if(status.match(/^[\d|\/|\ ]*$/) !== null) return 'default';
        return 'warning';
    }

    // 提出IDから問題名を取得
    function requestProblemNameFromID(contestName, id){
        const requestURL = 'https://beta.atcoder.jp/contests/' + contestName + '/submissions/' + id;
        let retVal = "ERROR: can't load ProblemName";

        return new Promise(function(resolve){
            $.ajax({
                type: 'GET',
                url: requestURL,
                dataType: 'html',
            })
            .done(function(data){
                const table = $($.parseHTML(data)).find('table');
                if(table) {
                    let nameWithLink = $(table[0]).find('td')[1].innerHTML;

                    // リンクを別タブで開くようにする
                    retVal = nameWithLink.replace(/">/, '" target="_blank">');
                }
            })
            .always(function(){
                resolve(retVal);
            });
        });
    }

    // localStorageのlastFocusedAtを調べてWJを調べる必要があるか確認
    function isValidLastFocusedAt() {
        const storedLastFocusedAt = localStorage.getItem(storageKey_lastFetchedAt);

        if(!storedLastFocusedAt) return false;

        // このタブより後に別のタブがアクティブになっていたら調べない
        if(lastFocusedAt !== Number(storedLastFocusedAt)) return false;

        // 非アクティブになってから5分経っていたら調べない
        if(new Date().getTime() > lastFocusedAt + 5 * 60 * 1000) return false;

        return true;
    }

    // コンテストcontestNameのIDの提出を確認
    function reloadWJ(contestName, ID, jsonData) {
        return new Promise(function(resolve){
            if(jsonData[ID]){
                const html = $.parseHTML(jsonData[ID].Html);
                const resultStatus = $(html[0]).find('span').text(); // WJ, 2/7, AC, 2/7 WA, TLE, RE,...
                const resultLabel = classifyStatus(resultStatus); // WJ or AC or WA

                // WJがWJではなくなったら通知
                if(resultLabel !== 'default'){
                    let execTime = "", usedMemory = "";
                    if(html.length > 1){
                        execTime = $(html[1]).text();
                        usedMemory = $(html[2]).text();
                    }

                    let problemName = null;
                    requestProblemNameFromID(contestName, ID) // 提出IDから問題名を取得(非同期)
                    .then(function(name){
                        problemName = name;

                        // 通知コンテナに通知を追加
                        $('#AtCoderResultNotifier-container').append('<div class="AtCoderResultNotifier-notification">' +
                             '<ul>' +
                                 '<li>' + problemName + '</li>' +
                                 '<li><a href="' + 'https://beta.atcoder.jp/contests/' + contestName + '/submissions/' + ID + '" target="_blank">#' + ID + '</a> <span class="label label-' + resultLabel + '">' + resultStatus + '</span> ' + execTime + ' ' + usedMemory + '</li>' +
                             '</ul>'+
                        '</div>');

                        lastNotifiedAt = new Date().getTime();

                        // 通知し終えたのでnullを返す
                        resolve();
                    });
                }
                else{
                    // まだWJなので引き続き確認を続ける
                    resolve(ID);
                }
            }
            else{
                // ここには来ない,再度確認する
                resolve(ID);
            }
        });
    }

    // コンテストcontestNameのWJを確認
    function reloadWJOnContest(contestName, WJjson) {
        return new Promise(function(resolve){
            const IDary = WJjson[contestName].ID;
            const requestURL = 'https://beta.atcoder.jp/contests/' + contestName + '/submissions/me/status/json?sids[]=' + IDary.join('&sids[]=');

            $.ajax({
                type: 'GET',
                url: requestURL,
                dataType: 'json',
            })
            .done(function(data) {
                let promisesOfContest = [];

                // 各WJを確認
                IDary.forEach(function(ID){
                    promisesOfContest.push(reloadWJ(contestName, ID, data));
                });

                // このcontestで全てのWJを処理し終えたらjsonを更新
                Promise.all(promisesOfContest)
                .then(function(IDs){
                    // WJでなくなった提出のIDはnullになるためフィルタリング
                    IDs = IDs.filter(v => v);

                    if(IDs.length > 0){
                        WJjson[contestName].ID = IDs;
                    }
                    else{
                        delete WJjson[contestName];
                    }

                    resolve();
                });
            })
            .fail(function(){
                console.log('ERROR: GET', requestURL);
                resolve();
            });
        });
    }

    // WJとなっている提出を全て確認
    function reloadAllWJ() {
        // 最終通知時刻から10秒経っていたらコンテナを初期化
        if(!lastNotifiedAt || new Date().getTime() > lastNotifiedAt + 10 * 1000){
            $('#AtCoderResultNotifier-container').empty();
            lastNotifiedAt = INF;
        }

        if(document.hasFocus()){
            lastFocusedAt = new Date().getTime();
            localStorage.setItem(storageKey_lastFetchedAt, lastFocusedAt);
        }

        // 非アクティブになってからある程度時間が経っていたら確認しない
        if(!isValidLastFocusedAt()){
            clearTimeout(timer);
            return;
        }

        // 既存のWJを取得
        let WJjson = JSON.parse(localStorage.getItem(storageKey_WJ));
        if(!WJjson) return;

        let promisesOfAll = [];

        // 各コンテストを確認
        for(const contestName in WJjson){
            // 最終アクセス時から10分経っていたらWJデータを削除
            const lastFetchedAt = WJjson[contestName].lastFetchedAt;
            if(!lastFetchedAt || new Date().getTime() > lastFetchedAt + 10 * 60 * 1000){
                delete WJjson[contestName];
                continue;
            }

            promisesOfAll.push(reloadWJOnContest(contestName, WJjson));
        }

        // 全てのcontestのWJを処理し終えたらlocalStorageを更新
        Promise.all(promisesOfAll)
        .then(function(){
            localStorage.setItem(storageKey_WJ, JSON.stringify(WJjson));
            timer = setTimeout(reloadAllWJ, interval);
        });
    }

    $(window)
    .bind("focus",function(){ //フォーカスが当たったら最終アクティブ時刻とタイマーを更新
        // 別のAtCoderタブ->このAtCoderタブと切り替えたときに二重でcallされるのを防ぐ
        setTimeout(function(){ 
            lastFocusedAt = new Date().getTime();
            localStorage.setItem(storageKey_lastFetchedAt, lastFocusedAt);
        }, 50);

        // focusを繰り返されたときに複数個タイマーがセットされるのを防ぐ
        clearTimeout(timer);
        timer = setTimeout(reloadAllWJ, interval);
    })
    .bind("blur",function(){ //フォーカスが外れたら最終アクティブ時刻を設定するのみ,タイマーは更新しない
        lastFocusedAt = new Date().getTime();
        localStorage.setItem(storageKey_lastFetchedAt, lastFocusedAt);
    });

    // このスクリプトが読み込まれた時のアクティブ状態で初期化
    lastFocusedAt = new Date().getTime();
    localStorage.setItem(storageKey_lastFetchedAt, lastFocusedAt);
    
    timer = setTimeout(reloadAllWJ, interval);

    // コンテナを用意
    $('body').append('<div id="AtCoderResultNotifier-container"></div>');

    // 通知要素のスタイルを定義
    $('head').append('<style type="text/css">' + 
'#AtCoderResultNotifier-container{' + 
    'position: fixed;' + 
    'top: 120px;' +
    'left: 20px;' +
    'z-index: 1000;' +
'}' +
'.AtCoderResultNotifier-notification{' +
    'position: sticky;' +
    'top: 0;' +
    'left: 0;' +
    'background: #FFF;' +
    'border-radius: 4px;' +
    'border: medium solid #000;' +
    'cursor: pointer;' + 

    '-webkit-animation: AtCoderResultNotifier-fadeOut 7s ease 0s forwards;' +
    'animation: AtCoderResultNotifier-fadeOut 7s ease 0s forwards;' +
    'overflow:hidden;' +
'}' +
'@keyframes AtCoderResultNotifier-fadeOut {' +
    '  0% {opacity:0;height:  0px;}' +
    ' 15% {opacity:1;height:4.4em;}' +
    ' 85% {opacity:1;height:4.4em;border-width: 3px 3px;}' +
    '100% {opacity:0;height:  0px;border-width: 0px 3px;}' +
'}' +
'@-webkit-keyframes AtCoderResultNotifier-fadeOut {' +
    '  0% {opacity:0;height:  0px;}' +
    ' 15% {opacity:1;height:4.4em;}' +
    ' 85% {opacity:1;height:4.4em;border-width: 3px 3px;}' +
    '100% {opacity:0;height:  0px;border-width: 0px 3px;}' +
'}' +
'.AtCoderResultNotifier-notification>ul{' +
    'list-style: none;' +
    'margin: 0;' +
    'padding: .3em .8em 0 .8em;' +
'}' +
        '</style>');

    // 通知をクリックしたら消すようにする
    $(document).on('click', '.AtCoderResultNotifier-notification', function(){
        $(this).remove();
    });
});