AtCoder Problems Penalty Counter

AtCoder Problems のテーブルページ上で問題ごとにコンテスト中のペナルティ数を表示します

  1. // ==UserScript==
  2. // @name AtCoder Problems Penalty Counter
  3. // @namespace iilj
  4. // @version 2020.01.28.2
  5. // @description AtCoder Problems のテーブルページ上で問題ごとにコンテスト中のペナルティ数を表示します
  6. // @author iilj
  7. // @supportURL https://github.com/iilj/AtCoderProblemsExt/issues
  8. // @match https://kenkoooo.com/atcoder/*
  9. // @grant GM_addStyle
  10. // ==/UserScript==
  11.  
  12. (function () {
  13. 'use strict';
  14.  
  15. GM_addStyle(`
  16. span.appc-penalty {
  17. color: red;
  18. margin-left: .4rem;
  19. margin-right: .1rem;
  20. }
  21. `);
  22.  
  23. /**
  24. * AtCoder コンテストの URL を返す.
  25. *
  26. * @param {string} contestId コンテスト ID
  27. * @returns {string} AtCoder コンテストの URL
  28. */
  29. const getContestUrl = (contestId) => `https://atcoder.jp/contests/${contestId}`;
  30.  
  31. /**
  32. * AtCoder コンテストの問題 URL を返す.
  33. *
  34. * @param {string} contestId コンテスト ID
  35. * @param {string} problemId 問題 ID
  36. * @returns {string} AtCoder コンテストの問題 URL
  37. */
  38. const getProblemUrl = (contestId, problemId) => `${getContestUrl(contestId)}/tasks/${problemId}`;
  39.  
  40. /**
  41. * url string to json object
  42. *
  43. * @date 2020-01-15
  44. * @param {string} uri 取得するリソースのURI
  45. * @returns {Promise<Object[]>} 配列
  46. */
  47. async function getJson(uri) {
  48. const response = await fetch(uri);
  49. /** @type {Object[]} */
  50. const obj = await response.json();
  51. return obj;
  52. }
  53.  
  54. /**
  55. * get contestId->contest map and contestUrl->contestId map
  56. *
  57. * @date 2020-01-15
  58. * @returns {Promise<Object[]>} array [contestId->contest map, contestUrl->contestId map]
  59. */
  60. async function getContestsMap() {
  61. const contests = await getJson('https://kenkoooo.com/atcoder/resources/contests.json');
  62. const contestsMap = contests.reduce((hash, contest) => {
  63. hash[contest.id] = contest;
  64. return hash;
  65. }, {});
  66. const contestsUrl2Id = contests.reduce((hash, contest) => {
  67. hash[getContestUrl(contest.id)] = contest.id;
  68. return hash;
  69. }, {});
  70. return [contestsMap, contestsUrl2Id];
  71. }
  72.  
  73. /**
  74. * return problemUrl->penalty map from userId string
  75. *
  76. * @date 2020-01-15
  77. * @param {string} userId
  78. * @returns {Promise<Object>} problemUrl->penalty map
  79. */
  80. async function getUserPenaltyMap(userId, contestsMap) {
  81. const userResults = await getJson(`https://kenkoooo.com/atcoder/atcoder-api/results?user=${userId}`);
  82. const userPenaltyMap = userResults.reduce((hash, submit) => {
  83. const key = getProblemUrl(submit.contest_id, submit.problem_id);
  84. const contest = contestsMap[submit.contest_id];
  85. if (!(key in hash)) {
  86. hash[key] = 0;
  87. }
  88. if (submit.epoch_second <= contest.start_epoch_second + contest.duration_second && submit.result != 'AC') {
  89. hash[key]++;
  90. }
  91. return hash;
  92. }, {});
  93. return userPenaltyMap;
  94. }
  95.  
  96. /**
  97. * Table 表示ページで "Show Accepted" の変更検知に利用する MutationObserver
  98. *
  99. * @type {MutationObserver}
  100. */
  101. let tableObserver;
  102.  
  103. /**
  104. * Table 表示ページで表のセルの色を塗り分ける.
  105. *
  106. * @date 2020-01-16
  107. * @param {string} userId
  108. */
  109. async function processTable(userId) {
  110. const [contestsMap, contestsUrl2Id] = await getContestsMap();
  111. const userPenaltyMap = await getUserPenaltyMap(userId, contestsMap);
  112.  
  113. const tableChanged = () => {
  114. if (tableObserver) {
  115. tableObserver.disconnect();
  116. }
  117. document.querySelectorAll('span.appc-penalty').forEach(spanPenalty => {
  118. spanPenalty.innerText = '';
  119. });
  120. document.querySelectorAll('td.table-problem').forEach(td => {
  121. const lnk = td.querySelector('a[href]');
  122. if (lnk && lnk.href in userPenaltyMap && userPenaltyMap[lnk.href] > 0) {
  123. const userPenalty = userPenaltyMap[lnk.href];
  124. let divTimespan = td.querySelector('.table-problem-timespan');
  125. if (!divTimespan) {
  126. divTimespan = document.createElement("div");
  127. divTimespan.classList.add('table-problem-timespan');
  128. td.insertAdjacentElement('beforeend', divTimespan);
  129. }
  130. let spanPenalty = divTimespan.querySelector('span.appc-penalty');
  131. if (!spanPenalty) {
  132. spanPenalty = document.createElement("span");
  133. spanPenalty.classList.add('appc-penalty');
  134. divTimespan.insertAdjacentElement('beforeend', spanPenalty);
  135. }
  136. spanPenalty.innerText = `(${userPenalty})`;
  137. }
  138. });
  139. if (tableObserver) {
  140. document.querySelectorAll('.react-bs-container-body').forEach(div => {
  141. tableObserver.observe(div, { childList: true, subtree: true });
  142. });
  143. }
  144. };
  145.  
  146. tableObserver = new MutationObserver(mutations => tableChanged());
  147. tableChanged();
  148. document.querySelectorAll('.react-bs-container-body').forEach(div => {
  149. tableObserver.observe(div, { childList: true, subtree: true });
  150. });
  151. }
  152.  
  153. /**
  154. * ページ URL が変化した際のルートイベントハンドラ.
  155. *
  156. * @date 2020-01-15
  157. */
  158. const hrefChanged = () => {
  159. if (tableObserver) {
  160. tableObserver.disconnect();
  161. }
  162.  
  163. /** @type {RegExpMatchArray} */
  164. let result;
  165. if (result = location.href.match(/^https?:\/\/kenkoooo\.com\/atcoder\/#\/table\/([^/?#]+)/)) {
  166. const userId = result[1];
  167. processTable(userId);
  168. }
  169. };
  170.  
  171. let href = location.href;
  172. const observer = new MutationObserver(mutations => {
  173. if (href === location.href) {
  174. return;
  175. }
  176. // href changed
  177. href = location.href;
  178. hrefChanged();
  179. });
  180. observer.observe(document, { childList: true, subtree: true });
  181. hrefChanged();
  182. })();