AtCoder Editorial Voting

AtCoderの解説に投票します。

当前为 2024-04-19 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name AtCoder Editorial Voting
  3. // @namespace https://atcoder.jp/
  4. // @version 2024-04-20
  5. // @description AtCoderの解説に投票します。
  6. // @license MIT
  7. // @author magurofly
  8. // @match https://atcoder.jp/contests/*/editorial
  9. // @match https://atcoder.jp/contests/*/editorial?*
  10. // @match https://atcoder.jp/contests/*/tasks/*/editorial
  11. // @match https://atcoder.jp/contests/*/tasks/*/editorial?*
  12. // @icon https://www.google.com/s2/favicons?sz=64&domain=atcoder.jp
  13. // @grant unsafeWindow
  14. // @grant GM_getValue
  15. // @grant GM_setValue
  16. // ==/UserScript==
  17.  
  18. // AtCoder で定義されている以下の変数を使用します
  19. // - contestScreenName
  20. // - userScreenName
  21. (function() {
  22. "use strict";
  23.  
  24. // このスクリプトの機能
  25. // - 解説リンクに投票スコアと投票ボタンを表示する
  26. // - バックエンドにログインする(ため、一時的に所属欄を書き換える)
  27. // - 投票する
  28.  
  29. let token = GM_getValue("token", null);
  30.  
  31. function canonicalizeEditorialLink(url) {
  32. const prefix = "https://atcoder.jp/jump?url=";
  33. if (url.startsWith(prefix)) {
  34. return decodeURIComponent(url.slice(prefix.length));
  35. }
  36. return url;
  37. }
  38.  
  39. function encodeFormData(data) {
  40. return Object.keys(data).map(key => encodeURIComponent(key) + "=" + encodeURIComponent(data[key]) ).join("&");
  41. }
  42.  
  43. async function callApi(name, body) {
  44. const result = await fetch("https://magurofly.zapto.org/" + name, {
  45. method: "POST",
  46. headers: {
  47. "Content-Type": "application/json",
  48. },
  49. body: JSON.stringify(body),
  50. }).then(res => res.json());
  51. if (result.status == "error") {
  52. if (result.reason == "invalid token") {
  53. token = null;
  54. }
  55. throw "Error: " + result.reason;
  56. }
  57. return result;
  58. }
  59.  
  60. async function login() {
  61. // 所属トークンを得る
  62. const affiliationTokenData = await callApi("create-affiliation-token", { atcoder_id: unsafeWindow.userScreenName });
  63. const affiliation_token = affiliationTokenData.affiliation_token;
  64.  
  65. // 設定を得る
  66. const profileSettings = new DOMParser().parseFromString(await fetch("https://atcoder.jp/settings").then(res => res.text()), "text/html");
  67. const data = {};
  68. for (const input of profileSettings.querySelector("#main-container form").elements) {
  69. data[input.name] = input.value;
  70. }
  71. const oldAffiliation = data["ui.Affiliation"];
  72.  
  73. // 所属に所属トークンを設定する
  74. data["ui.Affiliation"] = affiliation_token;
  75. await fetch("https://atcoder.jp/settings", {
  76. method: "POST",
  77. headers: {
  78. "Content-Type": "application/x-www-form-urlencoded",
  79. },
  80. body: encodeFormData(data),
  81. });
  82.  
  83. // 認証する
  84. const tokenData = await callApi("create-token", { atcoder_id: unsafeWindow.userScreenName, affiliation_token });
  85.  
  86. // 所属を元に戻す
  87. data["ui.Affiliation"] = oldAffiliation;
  88. await fetch("https://atcoder.jp/settings", {
  89. method: "POST",
  90. headers: {
  91. "Content-Type": "application/x-www-form-urlencoded",
  92. },
  93. body: encodeFormData(data),
  94. });
  95.  
  96. // トークンを保存する
  97. token = tokenData.token;
  98. GM_setValue("token", token);
  99. }
  100.  
  101. // 投票する
  102. async function sendVote(editorial, vote) {
  103. if (token == null) {
  104. await login();
  105. }
  106.  
  107. callApi("vote", {
  108. token,
  109. contest: unsafeWindow.contestScreenName,
  110. editorial,
  111. vote,
  112. });
  113. }
  114.  
  115. // レート分布を表示するやつ
  116. class Histogram {
  117. constructor() {
  118. this.canvas = document.createElement("canvas");
  119. this.canvas.width = 320;
  120. this.canvas.height = 160;
  121. this.ctx = this.canvas.getContext("2d");
  122. this.dist = [0, 0, 0, 0, 0, 0, 0, 0];
  123. this.draw();
  124. }
  125.  
  126. setRatingDistribution(dist) {
  127. this.dist = dist;
  128. this.draw();
  129. }
  130.  
  131. draw() {
  132. const colors = ["#808080", "#804000", "#008000", "#00C0C0", "#0000FF", "#C0C000", "#FF8000", "#FF0000"];
  133. const vHalf = this.canvas.height / 2;
  134. const vUnit = (vHalf - 16) / Math.max(4, ...this.dist.map(y => Math.abs(y)));
  135. const hUnit = this.canvas.width / 8;
  136. this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
  137. this.ctx.fillStyle = "#333";
  138. this.ctx.fillRect(0, this.canvas.height / 2 - 1, hUnit * 8, 2);
  139. this.ctx.font = "12px serif";
  140. this.ctx.textAlign = "center";
  141. for (let i = 0; i < 8; i++) {
  142. const x = hUnit * i;
  143. const value = this.dist[i];
  144. this.ctx.fillStyle = colors[i];
  145. if (value > 0) {
  146. this.ctx.fillRect(x, vHalf - 1 - vUnit * value, hUnit, vUnit * value - 1);
  147. this.ctx.fillStyle = "#333";
  148. this.ctx.fillText(value.toString(), x + hUnit / 2, vHalf - 4 - vUnit * value);
  149. } else if (value < 0) {
  150. this.ctx.fillRect(x, vHalf + 1 + vUnit * -value, hUnit, vUnit * value - 1);
  151. this.ctx.fillStyle = "#333";
  152. this.ctx.fillText(value.toString(), x + hUnit / 2, vHalf + 16 + vUnit * -value);
  153. }
  154. }
  155. }
  156. }
  157.  
  158. // 解説リンクにスコアと投票ボタンを表示する
  159. class Voting {
  160. constructor(editorial, elements) {
  161. this.editorial = editorial;
  162. this.elements = elements;
  163. this.score = 0;
  164. this.vote = 0;
  165.  
  166. elements.btnUpVote.onclick = this.setVote.bind(this, 1);
  167. elements.btnDownVote.onclick = this.setVote.bind(this, -1);
  168. }
  169.  
  170. setCurrentVote(score, vote, dist) {
  171. this.vote = vote;
  172. this.score = score;
  173. this.elements.scoreView.textContent = score;
  174. this.elements.histogram.setRatingDistribution(dist);
  175. if (vote == 1) {
  176. this.elements.btnUpVote.classList.add("active");
  177. this.elements.btnUpVote.onclick = this.setVote.bind(this, 0);
  178. this.elements.btnDownVote.classList.remove("active");
  179. this.elements.btnDownVote.onclick = this.setVote.bind(this, -1);
  180. } else if (vote == -1) {
  181. this.elements.btnUpVote.classList.remove("active");
  182. this.elements.btnUpVote.onclick = this.setVote.bind(this, 1);
  183. this.elements.btnDownVote.classList.add("active");
  184. this.elements.btnDownVote.onclick = this.setVote.bind(this, 0);
  185. } else {
  186. this.elements.btnUpVote.classList.remove("active");
  187. this.elements.btnUpVote.onclick = this.setVote.bind(this, 1);
  188. this.elements.btnDownVote.classList.remove("active");
  189. this.elements.btnDownVote.onclick = this.setVote.bind(this, -1);
  190. }
  191. }
  192.  
  193. async setVote(vote) {
  194. this.score += vote - this.vote;
  195. this.setCurrentVote(this.score, this.vote);
  196. if (vote == 1) {
  197. await sendVote(this.editorial, "up");
  198. } else if (vote == -1) {
  199. await sendVote(this.editorial, "down");
  200. } else {
  201. await sendVote(this.editorial, "none");
  202. }
  203. }
  204. }
  205.  
  206. const votes = [];
  207. for (const link of unsafeWindow.document.querySelectorAll("#main-container a[rel=noopener]")) {
  208. // リンク先を正規化する
  209. const editorial = canonicalizeEditorialLink(link.href);
  210.  
  211. // ここのデザインは burioden 様に助けていただきました
  212.  
  213. const scoreView = document.createElement("span");
  214. Object.assign(scoreView.style, {
  215. verticalAlign: "middle",
  216. display: "inline-block",
  217. boxSizing: "border-box",
  218. height: "100%",
  219. padding: "1px 5px",
  220. lineHeight: "1.5",
  221. borderTop: "1px solid #aaa",
  222. borderBottom: "1px solid #aaa",
  223. background: "transparent",
  224. color: "#333",
  225. fontSize: "12px",
  226. });
  227. scoreView.textContent = "0";
  228.  
  229. const btnUpVote = document.createElement("button");
  230. btnUpVote.className = "btn btn-xs btn-warning";
  231. Object.assign(btnUpVote.style, {
  232. border: "1px solid #aaa",
  233. borderRadius: "0 5px 5px 0",
  234. height: "100%",
  235. });
  236. btnUpVote.type = "button";
  237. btnUpVote.textContent = "+";
  238.  
  239. const btnDownVote = document.createElement("button");
  240. btnDownVote.className = "btn btn-xs btn-info";
  241. Object.assign(btnDownVote.style, {
  242. border: "1px solid #aaa",
  243. borderRadius: "5px 0 0 5px",
  244. height: "100%",
  245. });
  246. btnDownVote.type = "button";
  247. btnDownVote.textContent = "-";
  248.  
  249. const buttonGroup = document.createElement("span");
  250. Object.assign(buttonGroup.style, {
  251. display: "inline-block",
  252. height: "1.5em",
  253. margin: "0 8px",
  254. });
  255. buttonGroup.appendChild(btnDownVote);
  256. buttonGroup.appendChild(scoreView);
  257. buttonGroup.appendChild(btnUpVote);
  258. link.parentElement.insertBefore(buttonGroup, link);
  259.  
  260. // キャンバスをつくる
  261. const anchor = buttonGroup.getBoundingClientRect();
  262. const histogram = new Histogram();
  263. Object.assign(histogram.canvas.style, {
  264. position: "absolute",
  265. left: anchor.x + anchor.width * 0.5 + "px",
  266. top: anchor.y + anchor.height + "px",
  267. display: "none",
  268. border: "1px solid #aaa",
  269. background: "#fff",
  270. boxShadow: "10px 5px 5px #333",
  271. });
  272. document.body.appendChild(histogram.canvas);
  273. scoreView.addEventListener("mouseover", () => {
  274. histogram.canvas.style.display = "block";
  275. });
  276. scoreView.addEventListener("mouseout", () => {
  277. histogram.canvas.style.display = "none";
  278. });
  279.  
  280. votes.push(new Voting(editorial, { scoreView, btnUpVote, btnDownVote, buttonGroup, histogram }));
  281. }
  282.  
  283. callApi("statuses", { token, editorials: votes.map(v => v.editorial) }).then(res => {
  284. for (let i = 0; i < res.results.length; i++) {
  285. const { score, scores_by_rating, current_vote } = res.results[i];
  286. const vote = current_vote == "up" ? 1 : current_vote == "down" ? -1 : 0;
  287. const dist = [0, 0, 0, 0, 0, 0, 0, 0];
  288. for (const [key, value] of Object.entries(scores_by_rating)) {
  289. const rating = parseInt(key.split("-")[0]);
  290. if (rating < 2800) {
  291. dist[Math.trunc(rating / 400)] += value;
  292. } else {
  293. dist[7] += value;
  294. }
  295. }
  296. votes[i].setCurrentVote(score, vote, dist);
  297. }
  298. });
  299. })();