AtCoderResultNotifier

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

As of 2018-08-15. See the latest version.

  1. // ==UserScript==
  2. // @name AtCoderResultNotifier
  3. // @namespace https://satanic0258.github.io/
  4. // @version 1.0.0
  5. // @description Send submission result notifications on AtCoder. You MUST install AtCoderResultNotifier_WJCollecter too.
  6. // @author satanic0258
  7. // @include https://beta.atcoder.jp/*
  8. // @grant none
  9. // @require https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js
  10. // ==/UserScript==
  11.  
  12. /*jshint esversion: 6 */
  13.  
  14. $(function() {
  15. 'use strict';
  16.  
  17. // 読み込み元と整合性を取る
  18. // https://wiki.greasespot.net/Third-Party_Libraries
  19. this.$ = this.jQuery = jQuery.noConflict(true);
  20.  
  21. // localStorageに保存するキー
  22. const storageKey_WJ = 'AtCoderResultNotifier_WJ';
  23. const storageKey_lastFetchedAt = 'AtCoderResultNotifier_lastFetchedAt';
  24.  
  25. // リクエスト間隔(デフォルトは公式と同じ3500ms)
  26. const interval = 3500;
  27. let timer = null;
  28.  
  29. // 充分大きい値 (充分大きい時刻として使用)
  30. const INF = (Number.MAX_SAFE_INTEGER - 1) / 2;
  31.  
  32. // 最終通知時刻 (コンテナ初期化に使用)
  33. let lastNotifiedAt = null;
  34.  
  35. // このタブで最後にアクティブだった時刻 (非アクティブ時の動作判定で使用)
  36. let lastFocusedAt = INF;
  37.  
  38. // ジャッジステータスを大別
  39. function classifyStatus(status){ // => ['default', 'success', 'warning']
  40. if(status === 'WJ') return 'default';
  41. if(status === 'AC') return 'success';
  42. if(status.match(/^[\d|\/|\ ]*$/) !== null) return 'default';
  43. return 'warning';
  44. }
  45.  
  46. // 提出IDから問題名を取得
  47. function requestProblemNameFromID(contestName, id){
  48. const requestURL = 'https://beta.atcoder.jp/contests/' + contestName + '/submissions/' + id;
  49. let retVal = "ERROR: can't load ProblemName";
  50.  
  51. return new Promise(function(resolve){
  52. $.ajax({
  53. type: 'GET',
  54. url: requestURL,
  55. dataType: 'html',
  56. })
  57. .done(function(data){
  58. const table = $($.parseHTML(data)).find('table');
  59. if(table) {
  60. let nameWithLink = $(table[0]).find('td')[1].innerHTML;
  61.  
  62. // リンクを別タブで開くようにする
  63. retVal = nameWithLink.replace(/">/, '" target="_blank">');
  64. }
  65. })
  66. .always(function(){
  67. resolve(retVal);
  68. });
  69. });
  70. }
  71.  
  72. // localStorageのlastFocusedAtを調べてWJを調べる必要があるか確認
  73. function isValidLastFocusedAt() {
  74. const storedLastFocusedAt = localStorage.getItem(storageKey_lastFetchedAt);
  75.  
  76. if(!storedLastFocusedAt) return false;
  77.  
  78. // このタブより後に別のタブがアクティブになっていたら調べない
  79. if(lastFocusedAt !== Number(storedLastFocusedAt)) return false;
  80.  
  81. // 非アクティブになってから5分経っていたら調べない
  82. if(new Date().getTime() > lastFocusedAt + 5 * 60 * 1000) return false;
  83.  
  84. return true;
  85. }
  86.  
  87. // コンテストcontestNameのIDの提出を確認
  88. function reloadWJ(contestName, ID, jsonData) {
  89. return new Promise(function(resolve){
  90. if(jsonData[ID]){
  91. const html = $.parseHTML(jsonData[ID].Html);
  92. const resultStatus = $(html[0]).find('span').text(); // WJ, 2/7, AC, 2/7 WA, TLE, RE,...
  93. const resultLabel = classifyStatus(resultStatus); // WJ or AC or WA
  94.  
  95. // WJがWJではなくなったら通知
  96. if(resultLabel !== 'default'){
  97. let execTime = "", usedMemory = "";
  98. if(html.length > 1){
  99. execTime = $(html[1]).text();
  100. usedMemory = $(html[2]).text();
  101. }
  102.  
  103. let problemName = null;
  104. requestProblemNameFromID(contestName, ID) // 提出IDから問題名を取得(非同期)
  105. .then(function(name){
  106. problemName = name;
  107.  
  108. // 通知コンテナに通知を追加
  109. $('#AtCoderResultNotifier-container').append('<div class="AtCoderResultNotifier-notification">' +
  110. '<ul>' +
  111. '<li>' + problemName + '</li>' +
  112. '<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>' +
  113. '</ul>'+
  114. '</div>');
  115.  
  116. lastNotifiedAt = new Date().getTime();
  117.  
  118. // 通知し終えたのでnullを返す
  119. resolve();
  120. });
  121. }
  122. else{
  123. // まだWJなので引き続き確認を続ける
  124. resolve(ID);
  125. }
  126. }
  127. else{
  128. // ここには来ない,再度確認する
  129. resolve(ID);
  130. }
  131. });
  132. }
  133.  
  134. // コンテストcontestNameのWJを確認
  135. function reloadWJOnContest(contestName, WJjson) {
  136. return new Promise(function(resolve){
  137. const IDary = WJjson[contestName].ID;
  138. const requestURL = 'https://beta.atcoder.jp/contests/' + contestName + '/submissions/me/status/json?sids[]=' + IDary.join('&sids[]=');
  139.  
  140. $.ajax({
  141. type: 'GET',
  142. url: requestURL,
  143. dataType: 'json',
  144. })
  145. .done(function(data) {
  146. let promisesOfContest = [];
  147.  
  148. // 各WJを確認
  149. IDary.forEach(function(ID){
  150. promisesOfContest.push(reloadWJ(contestName, ID, data));
  151. });
  152.  
  153. // このcontestで全てのWJを処理し終えたらjsonを更新
  154. Promise.all(promisesOfContest)
  155. .then(function(IDs){
  156. // WJでなくなった提出のIDはnullになるためフィルタリング
  157. IDs = IDs.filter(v => v);
  158.  
  159. if(IDs.length > 0){
  160. WJjson[contestName].ID = IDs;
  161. }
  162. else{
  163. delete WJjson[contestName];
  164. }
  165.  
  166. resolve();
  167. });
  168. })
  169. .fail(function(){
  170. console.log('ERROR: GET', requestURL);
  171. resolve();
  172. });
  173. });
  174. }
  175.  
  176. // WJとなっている提出を全て確認
  177. function reloadAllWJ() {
  178. // 最終通知時刻から10秒経っていたらコンテナを初期化
  179. if(!lastNotifiedAt || new Date().getTime() > lastNotifiedAt + 10 * 1000){
  180. $('#AtCoderResultNotifier-container').empty();
  181. lastNotifiedAt = INF;
  182. }
  183.  
  184. if(document.hasFocus()){
  185. lastFocusedAt = new Date().getTime();
  186. localStorage.setItem(storageKey_lastFetchedAt, lastFocusedAt);
  187. }
  188.  
  189. // 非アクティブになってからある程度時間が経っていたら確認しない
  190. if(!isValidLastFocusedAt()){
  191. clearTimeout(timer);
  192. return;
  193. }
  194.  
  195. // 既存のWJを取得
  196. let WJjson = JSON.parse(localStorage.getItem(storageKey_WJ));
  197. if(!WJjson) return;
  198.  
  199. let promisesOfAll = [];
  200.  
  201. // 各コンテストを確認
  202. for(const contestName in WJjson){
  203. // 最終アクセス時から10分経っていたらWJデータを削除
  204. const lastFetchedAt = WJjson[contestName].lastFetchedAt;
  205. if(!lastFetchedAt || new Date().getTime() > lastFetchedAt + 10 * 60 * 1000){
  206. delete WJjson[contestName];
  207. continue;
  208. }
  209.  
  210. promisesOfAll.push(reloadWJOnContest(contestName, WJjson));
  211. }
  212.  
  213. // 全てのcontestのWJを処理し終えたらlocalStorageを更新
  214. Promise.all(promisesOfAll)
  215. .then(function(){
  216. localStorage.setItem(storageKey_WJ, JSON.stringify(WJjson));
  217. timer = setTimeout(reloadAllWJ, interval);
  218. });
  219. }
  220.  
  221. $(window)
  222. .bind("focus",function(){ //フォーカスが当たったら最終アクティブ時刻とタイマーを更新
  223. // 別のAtCoderタブ->このAtCoderタブと切り替えたときに二重でcallされるのを防ぐ
  224. setTimeout(function(){
  225. lastFocusedAt = new Date().getTime();
  226. localStorage.setItem(storageKey_lastFetchedAt, lastFocusedAt);
  227. }, 50);
  228.  
  229. // focusを繰り返されたときに複数個タイマーがセットされるのを防ぐ
  230. clearTimeout(timer);
  231. timer = setTimeout(reloadAllWJ, interval);
  232. })
  233. .bind("blur",function(){ //フォーカスが外れたら最終アクティブ時刻を設定するのみ,タイマーは更新しない
  234. lastFocusedAt = new Date().getTime();
  235. localStorage.setItem(storageKey_lastFetchedAt, lastFocusedAt);
  236. });
  237.  
  238. // このスクリプトが読み込まれた時のアクティブ状態で初期化
  239. lastFocusedAt = new Date().getTime();
  240. localStorage.setItem(storageKey_lastFetchedAt, lastFocusedAt);
  241. timer = setTimeout(reloadAllWJ, interval);
  242.  
  243. // コンテナを用意
  244. $('body').append('<div id="AtCoderResultNotifier-container"></div>');
  245.  
  246. // 通知要素のスタイルを定義
  247. $('head').append('<style type="text/css">' +
  248. '#AtCoderResultNotifier-container{' +
  249. 'position: fixed;' +
  250. 'top: 120px;' +
  251. 'left: 20px;' +
  252. 'z-index: 1000;' +
  253. '}' +
  254. '.AtCoderResultNotifier-notification{' +
  255. 'position: sticky;' +
  256. 'top: 0;' +
  257. 'left: 0;' +
  258. 'background: #FFF;' +
  259. 'border-radius: 4px;' +
  260. 'border: medium solid #000;' +
  261. 'cursor: pointer;' +
  262.  
  263. '-webkit-animation: AtCoderResultNotifier-fadeOut 7s ease 0s forwards;' +
  264. 'animation: AtCoderResultNotifier-fadeOut 7s ease 0s forwards;' +
  265. 'overflow:hidden;' +
  266. '}' +
  267. '@keyframes AtCoderResultNotifier-fadeOut {' +
  268. ' 0% {opacity:0;height: 0px;}' +
  269. ' 15% {opacity:1;height:4.4em;}' +
  270. ' 85% {opacity:1;height:4.4em;border-width: 3px 3px;}' +
  271. '100% {opacity:0;height: 0px;border-width: 0px 3px;}' +
  272. '}' +
  273. '@-webkit-keyframes AtCoderResultNotifier-fadeOut {' +
  274. ' 0% {opacity:0;height: 0px;}' +
  275. ' 15% {opacity:1;height:4.4em;}' +
  276. ' 85% {opacity:1;height:4.4em;border-width: 3px 3px;}' +
  277. '100% {opacity:0;height: 0px;border-width: 0px 3px;}' +
  278. '}' +
  279. '.AtCoderResultNotifier-notification>ul{' +
  280. 'list-style: none;' +
  281. 'margin: 0;' +
  282. 'padding: .3em .8em 0 .8em;' +
  283. '}' +
  284. '</style>');
  285.  
  286. // 通知をクリックしたら消すようにする
  287. $(document).on('click', '.AtCoderResultNotifier-notification', function(){
  288. $(this).remove();
  289. });
  290. });