comfortable-yukicoder

yukicoder にいくつかの機能を追加します.主に動線を増やします.

  1. // ==UserScript==
  2. // @name comfortable-yukicoder
  3. // @namespace iilj
  4. // @version 1.1.1
  5. // @description yukicoder にいくつかの機能を追加します.主に動線を増やします.
  6. // @author iilj
  7. // @license MIT
  8. // @supportURL https://github.com/iilj/comfortable-yukicoder/issues
  9. // @match https://yukicoder.me/contests/*
  10. // @match https://yukicoder.me/contests/*/*
  11. // @match https://yukicoder.me/problems/no/*
  12. // @match https://yukicoder.me/problems/*
  13. // @match https://yukicoder.me/submissions/*
  14. // @grant GM_addStyle
  15. // ==/UserScript==
  16. var css$1 = "div#js-cy-timer {\n position: fixed;\n right: 10px;\n bottom: 10px;\n width: 140px;\n height: 70px;\n margin: 0;\n text-align: center;\n line-height: 20px;\n font-size: 15px;\n z-index: 50;\n border: 7px solid #36353a;\n border-radius: 7px;\n background-color: #bdc4bd;\n padding: 8px 0;\n}";
  17. const pad = (num, length = 2) => `00${num}`.slice(-length);
  18. const days = ['日', '月', '火', '水', '木', '金', '土'];
  19. const formatDate = (date, format = '%Y-%m-%d (%a) %H:%M:%S.%f %z') => {
  20. const offset = date.getTimezoneOffset();
  21. const offsetSign = offset < 0 ? '+' : '-';
  22. const offsetHours = Math.floor(Math.abs(offset) / 60);
  23. const offsetMinutes = Math.abs(offset) % 60;
  24. let ret = format.replace(/%Y/g, String(date.getFullYear()));
  25. ret = ret.replace(/%m/g, pad(date.getMonth() + 1));
  26. ret = ret.replace(/%d/g, pad(date.getDate()));
  27. ret = ret.replace(/%a/g, days[date.getDay()]);
  28. ret = ret.replace(/%H/g, pad(date.getHours()));
  29. ret = ret.replace(/%M/g, pad(date.getMinutes()));
  30. ret = ret.replace(/%S/g, pad(date.getSeconds()));
  31. ret = ret.replace(/%f/g, pad(date.getMilliseconds(), 3));
  32. ret = ret.replace(/%z/g, `${offsetSign}${pad(offsetHours)}:${pad(offsetMinutes)}`);
  33. return ret;
  34. };
  35. const formatTime = (hours, minutes, seconds) => {
  36. return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
  37. };
  38. const diffMsToString = (diffMs) => {
  39. const diffWholeSecs = Math.ceil(diffMs / 1000);
  40. const diffSecs = diffWholeSecs % 60;
  41. const diffMinutes = Math.floor(diffWholeSecs / 60) % 60;
  42. const diffHours = Math.floor(diffWholeSecs / 3600) % 24;
  43. const diffDate = Math.floor(diffWholeSecs / (3600 * 24));
  44. const diffDateText = diffDate > 0 ? `${diffDate}日と` : '';
  45. return diffDateText + formatTime(diffHours, diffMinutes, diffSecs);
  46. };
  47. class Timer {
  48. constructor() {
  49. GM_addStyle(css$1);
  50. this.element = document.createElement('div');
  51. this.element.id = Timer.ELEMENT_ID;
  52. document.body.appendChild(this.element);
  53. this.top = document.createElement('div');
  54. this.element.appendChild(this.top);
  55. this.bottom = document.createElement('div');
  56. this.element.appendChild(this.bottom);
  57. this.prevSeconds = -1;
  58. this.startDate = undefined;
  59. this.endDate = undefined;
  60. this.intervalID = window.setInterval(() => {
  61. this.updateTime();
  62. }, 100);
  63. }
  64. updateTime() {
  65. const d = new Date();
  66. const seconds = d.getSeconds();
  67. if (seconds === this.prevSeconds)
  68. return;
  69. this.prevSeconds = seconds;
  70. if (this.startDate !== undefined && this.endDate !== undefined) {
  71. if (d < this.startDate) {
  72. this.top.textContent = '開始まであと';
  73. const diffMs = this.startDate.getTime() - d.getTime();
  74. this.bottom.textContent = diffMsToString(diffMs);
  75. }
  76. else if (d < this.endDate) {
  77. this.top.textContent = '残り時間';
  78. const diffMs = this.endDate.getTime() - d.getTime();
  79. this.bottom.textContent = diffMsToString(diffMs);
  80. }
  81. else {
  82. this.top.textContent = formatDate(d, '%Y-%m-%d (%a)');
  83. this.bottom.textContent = formatDate(d, '%H:%M:%S %z');
  84. }
  85. }
  86. else {
  87. this.top.textContent = formatDate(d, '%Y-%m-%d (%a)');
  88. this.bottom.textContent = formatDate(d, '%H:%M:%S %z');
  89. }
  90. }
  91. registerContest(contest) {
  92. this.startDate = new Date(contest.Date);
  93. this.endDate = new Date(contest.EndDate);
  94. }
  95. }
  96. Timer.ELEMENT_ID = 'js-cy-timer';
  97. var css = "#toplinks > div#cy-tabs-container > a {\n position: relative;\n background: linear-gradient(to bottom, white 0%, #fff2f3 100%);\n}\n#toplinks > div#cy-tabs-container > a ul.js-cy-contest-problems-ul {\n margin: 0;\n padding: 0;\n list-style-type: none;\n overflow: hidden;\n position: absolute;\n left: 0;\n top: 33px;\n width: max-content;\n min-height: 0;\n height: 0;\n z-index: 3;\n transition: min-height 0.4s;\n}\n#toplinks > div#cy-tabs-container > a ul.js-cy-contest-problems-ul > li > a {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n padding: 0.3rem;\n padding-left: 0.6rem;\n padding-right: 0.6rem;\n font-size: 16px;\n color: #fff;\n line-height: 1.75;\n background-color: #428bca;\n}\n#toplinks > div#cy-tabs-container > a ul.js-cy-contest-problems-ul > li > a:hover {\n background-color: #3071a9;\n}\n#toplinks > div#cy-tabs-container > a:hover {\n opacity: 1;\n}\n#toplinks > div#cy-tabs-container > a:hover ul.js-cy-contest-problems-ul {\n height: auto;\n}";
  98. const header = [
  99. 'A',
  100. 'B',
  101. 'C',
  102. 'D',
  103. 'E',
  104. 'F',
  105. 'G',
  106. 'H',
  107. 'I',
  108. 'J',
  109. 'K',
  110. 'L',
  111. 'M',
  112. 'N',
  113. 'O',
  114. 'P',
  115. 'Q',
  116. 'R',
  117. 'S',
  118. 'T',
  119. 'U',
  120. 'V',
  121. 'W',
  122. 'X',
  123. 'Y',
  124. 'Z',
  125. ];
  126. const getHeaderFromNum = (num) => {
  127. const idx = num - 1;
  128. if (idx < header.length) {
  129. return header[idx];
  130. }
  131. else {
  132. const r = idx % header.length;
  133. return getHeaderFromNum(Math.floor(idx / header.length)) + header[r];
  134. }
  135. };
  136. const getHeader = (idx) => getHeaderFromNum(idx + 1);
  137. class TopLinksManager {
  138. constructor() {
  139. GM_addStyle(css);
  140. const toplinks = document.querySelector('div#toplinks');
  141. if (toplinks === null) {
  142. throw Error('div#toplinks が見つかりません');
  143. }
  144. this.tabContainer = document.createElement('div');
  145. this.tabContainer.classList.add('left');
  146. this.tabContainer.id = TopLinksManager.TAB_CONTAINER_ID;
  147. toplinks.insertAdjacentElement('beforeend', this.tabContainer);
  148. this.id2element = new Map();
  149. }
  150. initLink(txt, id, href = '#') {
  151. const newtab = document.createElement('a');
  152. newtab.innerText = txt;
  153. newtab.id = id;
  154. newtab.setAttribute('href', href);
  155. this.tabContainer.appendChild(newtab);
  156. this.id2element.set(id, newtab);
  157. return newtab;
  158. }
  159. confirmLink(id, href) {
  160. const tab = this.id2element.get(id);
  161. if (tab === undefined) {
  162. throw new Error(`不明な id: ${id}`);
  163. }
  164. tab.href = href;
  165. }
  166. initContestSubmissions() {
  167. this.initLink('自分の提出', TopLinksManager.ID_CONTEST_SUBMISSION);
  168. }
  169. confirmContestSubmissions(contestId) {
  170. this.confirmLink(TopLinksManager.ID_CONTEST_SUBMISSION, `/contests/${contestId}/submissions?my_submission=enabled`);
  171. }
  172. initContestProblems() {
  173. this.initLink('コンテスト問題一覧', TopLinksManager.ID_CONTEST);
  174. }
  175. confirmContestProblems(contestId, contestProblems) {
  176. this.confirmLink(TopLinksManager.ID_CONTEST, `/contests/${contestId}`);
  177. this.addContestProblems(contestProblems);
  178. }
  179. initContestLinks() {
  180. this.initContestProblems();
  181. this.initLink('コンテスト順位表', TopLinksManager.ID_CONTEST_TABLE);
  182. this.initContestSubmissions();
  183. }
  184. confirmContestLinks(contestId, contestProblems) {
  185. this.confirmLink(TopLinksManager.ID_CONTEST_TABLE, `/contests/${contestId}/table`);
  186. this.confirmContestSubmissions(contestId);
  187. this.confirmContestProblems(contestId, contestProblems);
  188. }
  189. addContestProblems(contestProblems) {
  190. const tab = this.id2element.get(TopLinksManager.ID_CONTEST);
  191. if (tab === undefined) {
  192. throw new Error(`id=${TopLinksManager.ID_CONTEST} の要素が追加される前に更新が要求されました`);
  193. }
  194. const ul = document.createElement('ul');
  195. ul.classList.add('js-cy-contest-problems-ul');
  196. console.log(contestProblems);
  197. contestProblems.forEach((problem, index) => {
  198. console.log(problem);
  199. const li = document.createElement('li');
  200. const link = document.createElement('a');
  201. const header = getHeader(index);
  202. link.textContent = `${header} - ${problem.Title}`;
  203. if (problem.No !== null) {
  204. link.href = `/problems/no/${problem.No}`;
  205. }
  206. else {
  207. link.href = `/problems/${problem.ProblemId}`;
  208. }
  209. li.appendChild(link);
  210. ul.appendChild(li);
  211. });
  212. // add caret
  213. const caret = document.createElement('span');
  214. caret.classList.add('caret');
  215. tab.appendChild(caret);
  216. tab.insertAdjacentElement('beforeend', ul);
  217. }
  218. confirmWithoutContest(problem) {
  219. [TopLinksManager.ID_CONTEST, TopLinksManager.ID_CONTEST_TABLE].forEach((id) => {
  220. const tab = this.id2element.get(id);
  221. if (tab !== undefined)
  222. tab.remove();
  223. });
  224. // https://yukicoder.me/problems/no/5000/submissions?my_submission=enabled
  225. if (problem.No !== null) {
  226. this.confirmLink(TopLinksManager.ID_CONTEST_SUBMISSION, `/problems/no/${problem.No}/submissions?my_submission=enabled`);
  227. }
  228. else {
  229. this.confirmLink(TopLinksManager.ID_CONTEST_SUBMISSION, `/problems/${problem.ProblemId}/submissions?my_submission=enabled`);
  230. }
  231. }
  232. }
  233. TopLinksManager.TAB_CONTAINER_ID = 'cy-tabs-container';
  234. TopLinksManager.ID_CONTEST = 'js-cy-contest';
  235. TopLinksManager.ID_CONTEST_TABLE = 'js-cy-contest-table';
  236. TopLinksManager.ID_CONTEST_SUBMISSION = 'js-cy-contest-submissions';
  237. const onContestPage = async (contestId, APIClient) => {
  238. const toplinksManager = new TopLinksManager();
  239. toplinksManager.initContestSubmissions();
  240. toplinksManager.confirmContestSubmissions(contestId);
  241. const timer = new Timer();
  242. const contest = await APIClient.fetchContestById(contestId);
  243. timer.registerContest(contest);
  244. };
  245. const getContestProblems = (contest, problems) => {
  246. const pid2problem = new Map();
  247. problems.forEach((problem) => {
  248. pid2problem.set(problem.ProblemId, problem);
  249. });
  250. const contestProblems = contest.ProblemIdList.map((problemId) => {
  251. const problem = pid2problem.get(problemId);
  252. if (problem !== undefined)
  253. return problem;
  254. return {
  255. No: null,
  256. ProblemId: problemId,
  257. Title: '',
  258. AuthorId: -1,
  259. TesterId: -1,
  260. TesterIds: '',
  261. Level: 0,
  262. ProblemType: 0,
  263. Tags: '',
  264. Date: null,
  265. Statistics: {
  266. //
  267. },
  268. };
  269. });
  270. return contestProblems;
  271. };
  272. const anchorToUserID = (anchor) => {
  273. const userLnkMatchArray = /^https:\/\/yukicoder\.me\/users\/(\d+)/.exec(anchor.href);
  274. if (userLnkMatchArray === null)
  275. return -1;
  276. const userId = Number(userLnkMatchArray[1]);
  277. return userId;
  278. };
  279. const getYourUserId = () => {
  280. const yourIdLnk = document.querySelector('#header #usermenu-btn');
  281. if (yourIdLnk === null)
  282. return -1; // ログインしていない場合
  283. return anchorToUserID(yourIdLnk);
  284. };
  285. const onLeaderboardPage = async (contestId, APIClient) => {
  286. const myRankTableRow = document.querySelector('table.table tbody tr.my_rank');
  287. if (myRankTableRow !== null) {
  288. const myRankTableRowCloned = myRankTableRow.cloneNode(true);
  289. const tbody = document.querySelector('table.table tbody');
  290. if (tbody === null) {
  291. throw new Error('順位表テーブルが見つかりません');
  292. }
  293. tbody.insertAdjacentElement('afterbegin', myRankTableRowCloned);
  294. // const myRankTableFirstRow: HTMLTableRowElement | null =
  295. // document.querySelector<HTMLTableRowElement>('table.table tbody tr.my_rank');
  296. // myRankTableFirstRow.style.borderBottom = '2px solid #ddd';
  297. myRankTableRowCloned.style.borderBottom = '2px solid #ddd';
  298. }
  299. const toplinksManager = new TopLinksManager();
  300. toplinksManager.initContestProblems();
  301. toplinksManager.initContestSubmissions();
  302. toplinksManager.confirmContestSubmissions(contestId);
  303. const timer = new Timer();
  304. const [problems, contest] = await Promise.all([APIClient.fetchProblems(), APIClient.fetchContestById(contestId)]);
  305. timer.registerContest(contest);
  306. const contestProblems = getContestProblems(contest, problems);
  307. toplinksManager.confirmContestProblems(contest.Id, contestProblems);
  308. };
  309. const createCard = () => {
  310. const newdiv = document.createElement('div');
  311. // styling newdiv
  312. newdiv.style.display = 'inline-block';
  313. newdiv.style.borderRadius = '2px';
  314. newdiv.style.padding = '10px';
  315. newdiv.style.margin = '10px 0px';
  316. newdiv.style.border = '1px solid rgb(59, 173, 214)';
  317. newdiv.style.backgroundColor = 'rgba(120, 197, 231, 0.1)';
  318. const newdivWrapper = document.createElement('div');
  319. newdivWrapper.appendChild(newdiv);
  320. return [newdiv, newdivWrapper];
  321. };
  322. class ContestInfoCard {
  323. constructor(isProblemPage = true) {
  324. this.isProblemPage = isProblemPage;
  325. const [card, cardWrapper] = createCard();
  326. this.card = card;
  327. {
  328. // create newdiv
  329. this.contestDiv = document.createElement('div');
  330. // add contest info
  331. this.contestLnk = document.createElement('a');
  332. this.contestLnk.innerText = '(fetching contest info...)';
  333. this.contestLnk.href = '#';
  334. this.contestDiv.appendChild(this.contestLnk);
  335. this.contestSuffix = document.createTextNode(` (id=---)`);
  336. this.contestDiv.appendChild(this.contestSuffix);
  337. // add problem info
  338. if (isProblemPage) {
  339. const space = document.createTextNode(` `);
  340. this.contestDiv.appendChild(space);
  341. this.problemLnk = document.createElement('a');
  342. this.problemLnk.innerText = '#?';
  343. this.problemLnk.href = '#';
  344. this.contestDiv.appendChild(this.problemLnk);
  345. this.problemSuffix = document.createTextNode(' (No.---)');
  346. this.contestDiv.appendChild(this.problemSuffix);
  347. }
  348. this.dateDiv = document.createElement('div');
  349. this.dateDiv.textContent = 'xxxx-xx-xx xx:xx:xx 〜 xxxx-xx-xx xx:xx:xx';
  350. // newdiv.innerText = `${contest.Name} (id=${contest.Id}) #${label} (No.${problem.No})`;
  351. card.appendChild(this.contestDiv);
  352. card.appendChild(this.dateDiv);
  353. if (isProblemPage) {
  354. this.prevNextProblemLinks = document.createElement('div');
  355. this.prevNextProblemLinks.textContent = '(情報取得中)';
  356. card.appendChild(this.prevNextProblemLinks);
  357. }
  358. }
  359. const content = document.querySelector('div#content');
  360. if (content === null) {
  361. throw new Error('div#content が見つかりませんでした');
  362. }
  363. content.insertAdjacentElement('afterbegin', cardWrapper);
  364. }
  365. confirmContest(contest) {
  366. this.contestLnk.innerText = `${contest.Name}`;
  367. this.contestLnk.href = `/contests/${contest.Id}`;
  368. this.contestSuffix.textContent = ` (id=${contest.Id})`;
  369. const format = '%Y-%m-%d (%a) %H:%M:%S';
  370. const start = formatDate(new Date(contest.Date), format);
  371. const end = formatDate(new Date(contest.EndDate), format);
  372. this.dateDiv.textContent = `${start} ${end}`;
  373. }
  374. confirmContestAndProblem(contest, problem, suffix = '') {
  375. this.confirmContest(contest);
  376. if (this.isProblemPage) {
  377. if (this.prevNextProblemLinks === undefined) {
  378. throw new ErrorEvent('prevNextProblemLinks が undefined です');
  379. }
  380. if (this.problemLnk === undefined) {
  381. throw new ErrorEvent('problemLnk が undefined です');
  382. }
  383. if (this.problemSuffix === undefined) {
  384. throw new ErrorEvent('problemSuffix が undefined です');
  385. }
  386. const idx = contest.ProblemIdList.findIndex((problemId) => problemId === problem.ProblemId);
  387. const label = getHeader(idx);
  388. this.problemLnk.innerText = `#${label}`;
  389. if (problem.No !== null) {
  390. this.problemLnk.href = `/problems/no/${problem.No}`;
  391. this.problemSuffix.textContent = ` (No.${problem.No})`;
  392. }
  393. else {
  394. this.problemLnk.href = `/problems/${problem.ProblemId}`;
  395. }
  396. this.prevNextProblemLinks.textContent = ' / ';
  397. if (idx > 0) {
  398. // prev
  399. const lnk = document.createElement('a');
  400. lnk.innerText = `←前の問題 (#${getHeader(idx - 1)})`;
  401. lnk.href = `/problems/${contest.ProblemIdList[idx - 1]}${suffix}`;
  402. this.prevNextProblemLinks.insertAdjacentElement('afterbegin', lnk);
  403. }
  404. if (idx + 1 < contest.ProblemIdList.length) {
  405. // next
  406. const lnk = document.createElement('a');
  407. lnk.innerText = `次の問題 (#${getHeader(idx + 1)})→`;
  408. lnk.href = `/problems/${contest.ProblemIdList[idx + 1]}${suffix}`;
  409. this.prevNextProblemLinks.insertAdjacentElement('beforeend', lnk);
  410. }
  411. }
  412. }
  413. confirmContestIsNotFound() {
  414. var _a, _b;
  415. this.contestLnk.remove();
  416. this.contestSuffix.remove();
  417. (_a = this.problemLnk) === null || _a === void 0 ? void 0 : _a.remove();
  418. (_b = this.problemSuffix) === null || _b === void 0 ? void 0 : _b.remove();
  419. this.dateDiv.remove();
  420. if (this.prevNextProblemLinks !== undefined) {
  421. this.prevNextProblemLinks.textContent = '(どのコンテストにも属さない問題です)';
  422. }
  423. }
  424. onProblemFetchFailed() {
  425. this.contestLnk.innerText = '???';
  426. if (this.prevNextProblemLinks !== undefined) {
  427. this.prevNextProblemLinks.textContent = '(情報が取得できませんでした)';
  428. }
  429. }
  430. }
  431. const onProblemPage = async (fetchProblem, suffix, APIClient) => {
  432. const toplinksManager = new TopLinksManager();
  433. toplinksManager.initContestLinks();
  434. const contestInfoCard = new ContestInfoCard();
  435. const timer = new Timer();
  436. try {
  437. const [problem, problems, currentContest, pastContest, futureContests] = await Promise.all([
  438. fetchProblem(),
  439. APIClient.fetchProblems(),
  440. APIClient.fetchCurrentContests(),
  441. APIClient.fetchPastContests(),
  442. APIClient.fetchFutureContests(),
  443. ]);
  444. const contests = currentContest.concat(pastContest);
  445. let contest = contests.find((contest) => contest.ProblemIdList.includes(problem.ProblemId));
  446. if (contest === undefined) {
  447. // 未来のコンテストから探してみる
  448. if (problem.ProblemId !== undefined) {
  449. const futureContest = futureContests.find((contest) => contest.ProblemIdList.includes(problem.ProblemId));
  450. if (futureContest !== undefined) {
  451. contest = futureContest;
  452. // print contest info
  453. // contestInfoCard.confirmContestAndProblem(futureContest, problem, suffix);
  454. // return null;
  455. }
  456. else {
  457. contestInfoCard.confirmContestIsNotFound();
  458. toplinksManager.confirmWithoutContest(problem);
  459. return null;
  460. }
  461. }
  462. else {
  463. contestInfoCard.confirmContestIsNotFound();
  464. toplinksManager.confirmWithoutContest(problem);
  465. return null;
  466. }
  467. }
  468. const contestProblems = getContestProblems(contest, problems);
  469. // print contest info
  470. contestInfoCard.confirmContestAndProblem(contest, problem, suffix);
  471. // add tabs
  472. toplinksManager.confirmContestLinks(contest.Id, contestProblems);
  473. timer.registerContest(contest);
  474. return problem;
  475. }
  476. catch (error) {
  477. contestInfoCard.onProblemFetchFailed();
  478. return null;
  479. }
  480. };
  481. const onProblemPageByNo = async (problemNo, suffix, APIClient) => {
  482. return onProblemPage(() => APIClient.fetchProblemByNo(problemNo), suffix, APIClient);
  483. };
  484. const onProblemPageById = async (problemId, suffix, APIClient) => {
  485. return onProblemPage(() => APIClient.fetchProblemById(problemId), suffix, APIClient);
  486. };
  487. const colorScoreRow = (row, authorId, testerIds, yourId) => {
  488. const userLnk = row.querySelector('td.table_username a');
  489. if (userLnk === null) {
  490. throw new Error('テーブル行内にユーザへのリンクが見つかりませんでした');
  491. }
  492. const userId = anchorToUserID(userLnk);
  493. if (userId === -1)
  494. return;
  495. if (userId === authorId) {
  496. row.style.backgroundColor = 'honeydew';
  497. const label = document.createElement('div');
  498. label.textContent = '[作問者]';
  499. userLnk.insertAdjacentElement('afterend', label);
  500. }
  501. else if (testerIds.includes(userId)) {
  502. row.style.backgroundColor = 'honeydew';
  503. const label = document.createElement('div');
  504. label.textContent = '[テスター]';
  505. userLnk.insertAdjacentElement('afterend', label);
  506. }
  507. if (userId === yourId) {
  508. row.style.backgroundColor = 'aliceblue';
  509. const label = document.createElement('div');
  510. label.textContent = '[あなた]';
  511. userLnk.insertAdjacentElement('afterend', label);
  512. }
  513. };
  514. const onProblemScorePage = (problem) => {
  515. const yourId = getYourUserId();
  516. const testerIds = problem.TesterIds.split(',').map((testerIdString) => Number(testerIdString));
  517. const rows = document.querySelectorAll('table.table tbody tr');
  518. rows.forEach((row) => {
  519. colorScoreRow(row, problem.AuthorId, testerIds, yourId);
  520. });
  521. };
  522. const colorSubmissionRow = (row, authorId, testerIds, yourId) => {
  523. const userLnk = row.querySelector('td.table_username a');
  524. if (userLnk === null) {
  525. throw new Error('テーブル行内にユーザへのリンクが見つかりませんでした');
  526. }
  527. const userId = anchorToUserID(userLnk);
  528. if (userId === -1)
  529. return;
  530. if (userId === authorId) {
  531. row.style.backgroundColor = 'honeydew';
  532. const label = document.createElement('div');
  533. label.textContent = '[作問者]';
  534. userLnk.insertAdjacentElement('afterend', label);
  535. }
  536. else if (testerIds.includes(userId)) {
  537. row.style.backgroundColor = 'honeydew';
  538. const label = document.createElement('div');
  539. label.textContent = '[テスター]';
  540. userLnk.insertAdjacentElement('afterend', label);
  541. }
  542. if (userId === yourId) {
  543. row.style.backgroundColor = 'aliceblue';
  544. const label = document.createElement('div');
  545. label.textContent = '[あなた]';
  546. userLnk.insertAdjacentElement('afterend', label);
  547. }
  548. };
  549. const onProblemSubmissionsPage = (problem) => {
  550. const yourId = getYourUserId();
  551. const testerIds = problem.TesterIds.split(',').map((testerIdString) => Number(testerIdString));
  552. const rows = document.querySelectorAll('table.table tbody tr');
  553. rows.forEach((row) => {
  554. colorSubmissionRow(row, problem.AuthorId, testerIds, yourId);
  555. });
  556. };
  557. const onContestSubmissionsPage = async (contestId, APIClient) => {
  558. const toplinksManager = new TopLinksManager();
  559. toplinksManager.initContestProblems();
  560. toplinksManager.initContestSubmissions();
  561. const contestInfoCard = new ContestInfoCard(false);
  562. const yourId = getYourUserId();
  563. const [contest, problems] = await Promise.all([APIClient.fetchContestById(contestId), APIClient.fetchProblems()]);
  564. // print contest info
  565. contestInfoCard.confirmContest(contest);
  566. // add tabs
  567. const contestProblems = getContestProblems(contest, problems);
  568. toplinksManager.confirmContestProblems(contest.Id, contestProblems);
  569. toplinksManager.confirmContestSubmissions(contest.Id);
  570. const problemId2Label = contest.ProblemIdList.reduce((curMap, problemId, idx) => curMap.set(problemId, getHeader(idx)), new Map());
  571. const problemNo2ProblemMap = problems.reduce((curMap, problem) => {
  572. if (problem.No !== null)
  573. curMap.set(problem.No, problem);
  574. return curMap;
  575. }, new Map());
  576. // collect problemNos
  577. const rows = document.querySelectorAll('table.table tbody tr');
  578. for (let i = 0; i < rows.length; i++) {
  579. const row = rows[i];
  580. // add label to each problem link
  581. const lnk = row.querySelector('td a[href^="/problems/no/"]');
  582. if (lnk === null) {
  583. throw new Error('テーブル行内に問題へのリンクが見つかりませんでした');
  584. }
  585. const contestSubmissionsPageProblemLnkMatchArray = /^https:\/\/yukicoder\.me\/problems\/no\/(\d+)/.exec(lnk.href);
  586. if (contestSubmissionsPageProblemLnkMatchArray === null) {
  587. throw new Error('テーブル行内に含まれる問題リンク先が不正です');
  588. }
  589. const problemNo = Number(contestSubmissionsPageProblemLnkMatchArray[1]);
  590. if (!problemNo2ProblemMap.has(problemNo)) {
  591. try {
  592. const problem = await APIClient.fetchProblemByNo(problemNo);
  593. problemNo2ProblemMap.set(problemNo, problem);
  594. }
  595. catch (error) {
  596. problemNo2ProblemMap.set(problemNo, null);
  597. }
  598. }
  599. const problem = problemNo2ProblemMap.get(problemNo);
  600. if (problem === null || problem === undefined)
  601. return;
  602. const label = problemId2Label.get(problem.ProblemId);
  603. if (label !== undefined)
  604. lnk.insertAdjacentText('afterbegin', `#${label} `);
  605. // color authors and testers
  606. const testerIds = problem.TesterIds.split(',').map((testerIdString) => Number(testerIdString));
  607. colorSubmissionRow(row, problem.AuthorId, testerIds, yourId);
  608. }
  609. };
  610. const SUBMISSION_STATUSES = ['AC', 'WA', 'TLE', '--', 'MLE', 'OLE', 'QLE', 'RE', 'CE', 'IE', 'NoOut'];
  611. const stringToStatus = (resultText) => {
  612. for (let i = 0; i < SUBMISSION_STATUSES.length; ++i) {
  613. if (SUBMISSION_STATUSES[i] == resultText)
  614. return SUBMISSION_STATUSES[i];
  615. }
  616. throw new Error(`未知のジャッジステータスです: ${resultText}`);
  617. };
  618. const onSubmissionResultPage = async (APIClient) => {
  619. const toplinksManager = new TopLinksManager();
  620. const contestInfoCard = new ContestInfoCard();
  621. const [resultCard, resultCardWrapper] = createCard();
  622. {
  623. // count
  624. const resultCountMap = SUBMISSION_STATUSES.reduce((prevMap, label) => prevMap.set(label, 0), new Map());
  625. // ジャッジ中(提出直後)は,このテーブルは存在しない
  626. const testTable = document.getElementById('test_table');
  627. if (testTable !== null) {
  628. const results = testTable.querySelectorAll('tbody tr td span.label');
  629. results.forEach((span) => {
  630. var _a;
  631. const resultText = span.textContent;
  632. if (resultText === null) {
  633. throw new Error('ジャッジ結果テキストが空です');
  634. }
  635. const resultLabel = stringToStatus(resultText.trim());
  636. const cnt = (_a = resultCountMap.get(resultLabel)) !== null && _a !== void 0 ? _a : 0;
  637. resultCountMap.set(resultLabel, cnt + 1);
  638. });
  639. }
  640. const content = document.querySelector('div#testcase_table h4');
  641. // 提出直後,ジャッジ中は null
  642. if (content !== null) {
  643. content.insertAdjacentElement('afterend', resultCardWrapper);
  644. // print result
  645. const addResultRow = (cnt, label) => {
  646. const resultEntry = document.createElement('div');
  647. const labelSpan = document.createElement('span');
  648. labelSpan.textContent = label;
  649. labelSpan.classList.add('label');
  650. labelSpan.classList.add(label === 'AC' ? 'label-success' : label === 'IE' ? 'label-danger' : 'label-warning');
  651. resultEntry.appendChild(labelSpan);
  652. const countSpan = document.createTextNode(` × ${cnt}`);
  653. resultEntry.appendChild(countSpan);
  654. resultCard.appendChild(resultEntry);
  655. };
  656. resultCountMap.forEach((cnt, label) => {
  657. if (cnt > 0)
  658. addResultRow(cnt, label);
  659. });
  660. }
  661. }
  662. const lnk = document.querySelector('div#content a[href^="/problems/no/"]');
  663. if (lnk === null) {
  664. throw new Error('結果ページ中に問題ページへのリンクが見つかりませんでした');
  665. }
  666. toplinksManager.initLink('問題', 'js-cy-problem', lnk.href);
  667. toplinksManager.initContestLinks();
  668. const submissionPageProblemLnkMatchArray = /^https:\/\/yukicoder\.me\/problems\/no\/(\d+)/.exec(lnk.href);
  669. if (submissionPageProblemLnkMatchArray === null) {
  670. throw new Error('結果ページに含まれる問題ページへのリンク先が不正です');
  671. }
  672. // get problems/contests info
  673. const problemNo = Number(submissionPageProblemLnkMatchArray[1]);
  674. const [problem, problems, currentContest, pastContest] = await Promise.all([
  675. APIClient.fetchProblemByNo(problemNo),
  676. APIClient.fetchProblems(),
  677. APIClient.fetchCurrentContests(),
  678. APIClient.fetchPastContests(),
  679. ]);
  680. const contests = currentContest.concat(pastContest);
  681. const contest = contests.find((contest) => contest.ProblemIdList.includes(problem.ProblemId));
  682. // add tabs
  683. if (contest !== undefined) {
  684. const contestProblems = getContestProblems(contest, problems);
  685. toplinksManager.confirmContestLinks(contest.Id, contestProblems);
  686. // print contest info
  687. contestInfoCard.confirmContestAndProblem(contest, problem);
  688. }
  689. };
  690. const BASE_URL = 'https://yukicoder.me';
  691. const STATIC_API_BASE_URL = `${BASE_URL}/api/v1`;
  692. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  693. const assertResultIsValid = (obj) => {
  694. if ('Message' in obj)
  695. throw new Error(obj.Message);
  696. };
  697. const fetchJson = async (url) => {
  698. const res = await fetch(url);
  699. if (!res.ok) {
  700. throw new Error(res.statusText);
  701. }
  702. const obj = (await res.json());
  703. assertResultIsValid(obj);
  704. return obj;
  705. };
  706. // TODO pid/no->contest, の変換も受け持つほうが良い?(html 解析絡みをこのクラスに隠蔽できる)
  707. // 「現在のコンテスト」
  708. class CachedAPIClient {
  709. constructor() {
  710. this.pastContestsMap = new Map();
  711. this.currentContestsMap = new Map();
  712. this.futureContestsMap = new Map();
  713. this.problemsMapById = new Map();
  714. this.problemsMapByNo = new Map();
  715. }
  716. async fetchPastContests() {
  717. if (this.pastContests === undefined) {
  718. this.pastContests = await fetchJson(`${STATIC_API_BASE_URL}/contest/past`);
  719. this.pastContests.forEach((contest) => {
  720. if (!this.pastContestsMap.has(contest.Id))
  721. this.pastContestsMap.set(contest.Id, contest);
  722. });
  723. }
  724. return this.pastContests;
  725. }
  726. async fetchCurrentContests() {
  727. if (this.currentContests === undefined) {
  728. this.currentContests = await fetchJson(`${STATIC_API_BASE_URL}/contest/current`);
  729. this.currentContests.forEach((contest) => {
  730. if (!this.currentContestsMap.has(contest.Id))
  731. this.currentContestsMap.set(contest.Id, contest);
  732. });
  733. }
  734. return this.currentContests;
  735. }
  736. async fetchFutureContests() {
  737. if (this.futureContests === undefined) {
  738. this.futureContests = await fetchJson(`${STATIC_API_BASE_URL}/contest/future`);
  739. this.futureContests.forEach((contest) => {
  740. if (!this.futureContestsMap.has(contest.Id))
  741. this.futureContestsMap.set(contest.Id, contest);
  742. });
  743. }
  744. return this.futureContests;
  745. }
  746. async fetchContestById(contestId) {
  747. if (this.pastContestsMap.has(contestId)) {
  748. return this.pastContestsMap.get(contestId);
  749. }
  750. if (this.currentContestsMap.has(contestId)) {
  751. return this.currentContestsMap.get(contestId);
  752. }
  753. if (this.futureContestsMap.has(contestId)) {
  754. return this.futureContestsMap.get(contestId);
  755. }
  756. const contest = await fetchJson(`${STATIC_API_BASE_URL}/contest/id/${contestId}`);
  757. const currentDate = new Date();
  758. const startDate = new Date(contest.Date);
  759. const endDate = new Date(contest.EndDate);
  760. if (currentDate > endDate) {
  761. this.pastContestsMap.set(contestId, contest);
  762. }
  763. else if (currentDate > startDate) {
  764. this.currentContestsMap.set(contestId, contest);
  765. }
  766. return contest;
  767. }
  768. async fetchProblems() {
  769. if (this.problems === undefined) {
  770. this.problems = await fetchJson(`${STATIC_API_BASE_URL}/problems`);
  771. this.problems.forEach((problem) => {
  772. if (!this.problemsMapById.has(problem.ProblemId))
  773. this.problemsMapById.set(problem.ProblemId, problem);
  774. if (problem.No !== null && !this.problemsMapByNo.has(problem.No))
  775. this.problemsMapByNo.set(problem.No, problem);
  776. });
  777. }
  778. return this.problems;
  779. }
  780. async fetchProblemById(problemId) {
  781. if (this.problemsMapById.has(problemId)) {
  782. return this.problemsMapById.get(problemId);
  783. }
  784. try {
  785. const problem = await fetchJson(`${STATIC_API_BASE_URL}/problems/${problemId}`);
  786. this.problemsMapById.set(problem.ProblemId, problem);
  787. if (problem.No !== null)
  788. this.problemsMapByNo.set(problem.No, problem);
  789. return problem;
  790. }
  791. catch (_a) {
  792. await this.fetchProblems();
  793. if (this.problemsMapById.has(problemId)) {
  794. return this.problemsMapById.get(problemId);
  795. }
  796. // 問題一覧には載っていない -> 未来のコンテストの問題
  797. // ProblemId なので,未来のコンテスト一覧に載っている pid リストから,
  798. // コンテストは特定可能.
  799. return { ProblemId: problemId, No: null };
  800. }
  801. }
  802. async fetchProblemByNo(problemNo) {
  803. if (this.problemsMapByNo.has(problemNo)) {
  804. return this.problemsMapByNo.get(problemNo);
  805. }
  806. try {
  807. const problem = await fetchJson(`${STATIC_API_BASE_URL}/problems/no/${problemNo}`);
  808. this.problemsMapById.set(problem.ProblemId, problem);
  809. if (problem.No !== null)
  810. this.problemsMapByNo.set(problem.No, problem);
  811. return problem;
  812. }
  813. catch (_a) {
  814. await this.fetchProblems();
  815. if (this.problemsMapByNo.has(problemNo)) {
  816. return this.problemsMapByNo.get(problemNo);
  817. }
  818. // 問題一覧には載っていない -> 未来のコンテストの問題
  819. return { No: problemNo };
  820. }
  821. }
  822. }
  823. void (async () => {
  824. const href = location.href;
  825. const hrefMatchArray = /^https:\/\/yukicoder\.me(.+)/.exec(href);
  826. if (hrefMatchArray === null)
  827. return;
  828. const path = hrefMatchArray[1];
  829. const APIClient = new CachedAPIClient();
  830. // on problem page (ProblemNo)
  831. // e.g. https://yukicoder.me/problems/no/1313
  832. const problemPageMatchArray = /^\/problems\/no\/(\d+)(.*)/.exec(path);
  833. if (problemPageMatchArray !== null) {
  834. // get contest info
  835. const problemNo = Number(problemPageMatchArray[1]);
  836. const suffix = problemPageMatchArray[2];
  837. const problem = await onProblemPageByNo(problemNo, suffix, APIClient);
  838. if (problem === null)
  839. return;
  840. const problemSubmissionsPageMatchArray = /^\/problems\/no\/(\d+)\/submissions/.exec(path);
  841. if (problemSubmissionsPageMatchArray !== null) {
  842. onProblemSubmissionsPage(problem);
  843. }
  844. // on problem score page (ProblemNo)
  845. // e.g. https://yukicoder.me/problems/no/5004/score
  846. const problemScorePageMatchArray = /^\/problems\/no\/(\d+)\/score(.*)/.exec(path);
  847. if (problemScorePageMatchArray !== null) {
  848. onProblemScorePage(problem);
  849. }
  850. return;
  851. }
  852. // on problem page (ProblemId)
  853. // e.g. https://yukicoder.me/problems/5191
  854. const problemPageByIdMatchArray = /^\/problems\/(\d+)(.*)/.exec(path);
  855. if (problemPageByIdMatchArray !== null) {
  856. // get contest info
  857. const problemId = Number(problemPageByIdMatchArray[1]);
  858. const suffix = problemPageByIdMatchArray[2];
  859. const problem = await onProblemPageById(problemId, suffix, APIClient);
  860. if (problem === null)
  861. return;
  862. const problemSubmissionsPageMatchArray = /^\/problems\/(\d+)\/submissions/.exec(path);
  863. if (problemSubmissionsPageMatchArray !== null) {
  864. onProblemSubmissionsPage(problem);
  865. }
  866. return;
  867. }
  868. // on contest submissions page / statistics page
  869. // e.g. https://yukicoder.me/contests/300/submissions, https://yukicoder.me/contests/300/statistics
  870. const contestSubmissionsPageMatchArray = /^\/contests\/(\d+)\/(submissions|statistics)/.exec(path);
  871. if (contestSubmissionsPageMatchArray !== null) {
  872. const contestId = Number(contestSubmissionsPageMatchArray[1]);
  873. await onContestSubmissionsPage(contestId, APIClient);
  874. return;
  875. }
  876. // on submission result page
  877. // e.g. https://yukicoder.me/submissions/591424
  878. const submissionPageMatchArray = /^\/submissions\/\d+/.exec(path);
  879. if (submissionPageMatchArray !== null) {
  880. await onSubmissionResultPage(APIClient);
  881. return;
  882. }
  883. // on contest leaderboard page
  884. // e.g. https://yukicoder.me/contests/300/table
  885. const leaderboardPageMatchArray = /^\/contests\/(\d+)\/(table|all)/.exec(path);
  886. if (leaderboardPageMatchArray !== null) {
  887. const contestId = Number(leaderboardPageMatchArray[1]);
  888. await onLeaderboardPage(contestId, APIClient);
  889. return;
  890. }
  891. // on contest problem list page
  892. // e.g. https://yukicoder.me/contests/300
  893. const contestPageMatchArray = /^\/contests\/(\d+)$/.exec(path);
  894. if (contestPageMatchArray !== null) {
  895. const contestId = Number(contestPageMatchArray[1]);
  896. await onContestPage(contestId, APIClient);
  897. return;
  898. }
  899. })();