AtCoder Editorial Voting

AtCoderの解説に投票します。

目前为 2025-02-11 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name AtCoder Editorial Voting
  3. // @namespace https://atcoder.jp/
  4. // @version 2025-02-11
  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. // @match https://atcoder.jp/contests/*/editorial/*
  13. // @icon https://www.google.com/s2/favicons?sz=64&domain=atcoder.jp
  14. // @grant unsafeWindow
  15. // @grant GM_getValue
  16. // @grant GM_setValue
  17. // ==/UserScript==
  18.  
  19. // AtCoder で定義されている以下の変数を使用します
  20. // - contestScreenName
  21. // - userScreenName
  22. // 以下のサイトにアクセスします
  23. // - https://atcoder.jp/*
  24. // - https://editorial-voting-vercel-serverless-function.vercel.app/*
  25. (function() {
  26. "use strict";
  27.  
  28. // このスクリプトの機能
  29. // - 解説リンクに投票スコアと投票ボタンを表示する
  30. // - バックエンドにログインする(ため、一時的に所属欄を書き換える)
  31. // - 投票する
  32.  
  33. let token = GM_getValue("token", null);
  34.  
  35. function canonicalizeEditorialLink(url) {
  36. const prefix = "https://atcoder.jp/jump?url=";
  37. if (url.startsWith(prefix)) {
  38. return decodeURIComponent(url.slice(prefix.length));
  39. }
  40. return url;
  41. }
  42.  
  43. function encodeFormData(data) {
  44. return Object.keys(data).map(key => encodeURIComponent(key) + "=" + encodeURIComponent(data[key]) ).join("&");
  45. }
  46.  
  47. async function callApi(name, body) {
  48. const result = await fetch("https://editorial-voting-vercel-serverless-function.vercel.app/api/" + name, {
  49. method: "POST",
  50. headers: {
  51. "Content-Type": "application/json",
  52. },
  53. body: JSON.stringify(body),
  54. }).then(res => res.json());
  55. if (result.status == "error") {
  56. if (result.reason == "invalid token") {
  57. token = null;
  58. }
  59. throw "Error: " + result.reason;
  60. }
  61. return result;
  62. }
  63.  
  64. async function login() {
  65. // 所属トークンを得る
  66. const affiliationTokenData = await callApi("create_affiliation_token", { atcoder_id: unsafeWindow.userScreenName });
  67. const affiliation_token = affiliationTokenData.affiliation_token;
  68.  
  69. // 設定を得る
  70. const profileSettings = new DOMParser().parseFromString(await fetch("https://atcoder.jp/settings").then(res => res.text()), "text/html");
  71. const data = {};
  72. for (const input of profileSettings.querySelector("#main-container form").elements) {
  73. data[input.name] = input.value;
  74. }
  75. const oldAffiliation = data["ui.Affiliation"];
  76.  
  77. // 所属に所属トークンを設定する
  78. data["ui.Affiliation"] = affiliation_token;
  79. await fetch("https://atcoder.jp/settings", {
  80. method: "POST",
  81. headers: {
  82. "Content-Type": "application/x-www-form-urlencoded",
  83. },
  84. body: encodeFormData(data),
  85. });
  86.  
  87. // 認証する
  88. const tokenData = await callApi("create_token", { atcoder_id: unsafeWindow.userScreenName, affiliation_token });
  89.  
  90. // 所属を元に戻す
  91. data["ui.Affiliation"] = oldAffiliation;
  92. await fetch("https://atcoder.jp/settings", {
  93. method: "POST",
  94. headers: {
  95. "Content-Type": "application/x-www-form-urlencoded",
  96. },
  97. body: encodeFormData(data),
  98. });
  99.  
  100. // トークンを保存する
  101. token = tokenData.token;
  102. GM_setValue("token", token);
  103. }
  104.  
  105. // 投票する
  106. async function sendVote(editorial, vote) {
  107. if (token == null) {
  108. await login();
  109. }
  110.  
  111. await callApi("vote", {
  112. token,
  113. contest: unsafeWindow.contestScreenName,
  114. editorial,
  115. vote,
  116. });
  117. }
  118.  
  119. // レート分布を表示するやつ
  120. class HistogramComponent {
  121. constructor() {
  122. this.element = document.createElement("canvas");
  123. this.element.width = 320;
  124. this.element.height = 160;
  125. this.ctx = this.element.getContext("2d");
  126. this.dist = [0, 0, 0, 0, 0, 0, 0, 0];
  127. this.draw();
  128. }
  129.  
  130. setRatingDistribution(dist) {
  131. if (dist) this.dist = dist;
  132. this.draw();
  133. }
  134.  
  135. draw() {
  136. const colors = ["#808080", "#804000", "#008000", "#00C0C0", "#0000FF", "#C0C000", "#FF8000", "#FF0000"];
  137. const vHalf = this.element.height / 2;
  138. const vUnit = (vHalf - 16) / Math.max(4, ...this.dist.map(y => Math.abs(y)));
  139. const hUnit = this.element.width / 8;
  140. this.ctx.clearRect(0, 0, this.element.width, this.element.height);
  141. this.ctx.fillStyle = "#333";
  142. this.ctx.fillRect(0, this.element.height / 2 - 1, hUnit * 8, 2);
  143. this.ctx.font = "12px serif";
  144. this.ctx.textAlign = "center";
  145. for (let i = 0; i < 8; i++) {
  146. const x = hUnit * i;
  147. const value = this.dist[i];
  148. this.ctx.fillStyle = colors[i];
  149. 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 - 4 - vUnit * value);
  153. } else if (value < 0) {
  154. this.ctx.fillRect(x, vHalf + 1 + vUnit * -value, hUnit, vUnit * value - 1);
  155. this.ctx.fillStyle = "#333";
  156. this.ctx.fillText(value.toString(), x + hUnit / 2, vHalf + 16 + vUnit * -value);
  157. }
  158. }
  159. }
  160. }
  161.  
  162. // 解説リンクにスコアと投票ボタンを表示する
  163. // ここのデザインは burioden 様に助けていただきました
  164. class VoteComponent {
  165. constructor(editorial) {
  166. this.editorial = canonicalizeEditorialLink(editorial);
  167.  
  168. this.score = 0;
  169. this.vote = 0;
  170. this.dist = [0, 0, 0, 0, 0, 0, 0, 0];
  171. this.scoreView = document.createElement("span");
  172. Object.assign(this.scoreView.style, {
  173. verticalAlign: "middle",
  174. display: "inline-block",
  175. boxSizing: "border-box",
  176. height: "100%",
  177. padding: "1px 5px",
  178. lineHeight: "1.5",
  179. borderTop: "1px solid #aaa",
  180. borderBottom: "1px solid #aaa",
  181. background: "transparent",
  182. color: "#333",
  183. });
  184. this.scoreView.textContent = "0";
  185. this.btnUpVote = document.createElement("button");
  186. this.btnUpVote.className = "btn btn-xs btn-warning";
  187. Object.assign(this.btnUpVote.style, {
  188. border: "1px solid #aaa",
  189. borderRadius: "0 5px 5px 0",
  190. height: "100%",
  191. fontSize: "inherit",
  192. });
  193. this.btnUpVote.type = "button";
  194. this.btnUpVote.textContent = "+";
  195. this.btnUpVote.onclick = this.setVote.bind(this, 1);
  196. this.btnDownVote = document.createElement("button");
  197. this.btnDownVote.className = "btn btn-xs btn-info";
  198. Object.assign(this.btnDownVote.style, {
  199. border: "1px solid #aaa",
  200. borderRadius: "5px 0 0 5px",
  201. height: "100%",
  202. fontSize: "inherit",
  203. });
  204. this.btnDownVote.type = "button";
  205. this.btnDownVote.textContent = "-";
  206. this.btnDownVote.onclick = this.setVote.bind(this, -1);
  207.  
  208. // キャンバスをつくる
  209. this.histogram = new HistogramComponent();
  210. Object.assign(this.histogram.element.style, {
  211. position: "fixed",
  212. zIndex: 9999,
  213. display: "none",
  214. border: "1px solid #aaa",
  215. background: "#fff",
  216. boxShadow: "10px 5px 5px #333",
  217. });
  218. this.scoreView.addEventListener("mouseover", () => {
  219. const bounds = this.scoreView.getBoundingClientRect();
  220. this.histogram.element.style.left = `${bounds.x + bounds.width * 0.5}px`;
  221. this.histogram.element.style.top = `${bounds.y + bounds.height}px`;
  222. this.histogram.element.style.display = "block";
  223. });
  224. this.scoreView.addEventListener("mouseout", () => {
  225. this.histogram.element.style.display = "none";
  226. });
  227.  
  228. // 子供を追加
  229. this.component = document.createElement("span");
  230. this.component.appendChild(this.btnDownVote);
  231. this.component.appendChild(this.scoreView);
  232. this.component.appendChild(this.btnUpVote);
  233. this.component.appendChild(this.histogram.element);
  234. this.component.style.display = "none";
  235.  
  236. // ローダーを表示
  237. this.loader = document.createElement("span");
  238. this.loader.className = "glyphicon glyphicon-refresh glyphicon-refresh-animate";
  239.  
  240. this.element = document.createElement("span");
  241. this.element.appendChild(this.component);
  242. this.element.appendChild(this.loader);
  243. this.element.className = "editorial-voting-root-component";
  244. Object.assign(this.element.style, {
  245. position: "relative",
  246. overflow: "visible",
  247. display: "inline-block",
  248. height: "1.5em",
  249. margin: "0 8px",
  250. fontSize: "12px",
  251. });
  252. }
  253.  
  254. setLoading(isLoading) {
  255. if (isLoading) {
  256. this.component.style.display = "none";
  257. this.loader.style.display = "inline-block";
  258. } else {
  259. this.loader.style.display = "none";
  260. this.component.style.display = "inline-block";
  261. }
  262. }
  263.  
  264. setCurrentVote(score, vote, dist) {
  265. this.vote = vote;
  266. this.score = score;
  267. this.dist = dist;
  268. this.scoreView.textContent = score;
  269. this.histogram.setRatingDistribution(dist);
  270. if (vote == 1) {
  271. this.btnUpVote.classList.add("active");
  272. this.btnUpVote.onclick = this.setVote.bind(this, 0);
  273. this.btnDownVote.classList.remove("active");
  274. this.btnDownVote.onclick = this.setVote.bind(this, -1);
  275. } else if (vote == -1) {
  276. this.btnUpVote.classList.remove("active");
  277. this.btnUpVote.onclick = this.setVote.bind(this, 1);
  278. this.btnDownVote.classList.add("active");
  279. this.btnDownVote.onclick = this.setVote.bind(this, 0);
  280. } else {
  281. this.btnUpVote.classList.remove("active");
  282. this.btnUpVote.onclick = this.setVote.bind(this, 1);
  283. this.btnDownVote.classList.remove("active");
  284. this.btnDownVote.onclick = this.setVote.bind(this, -1);
  285. }
  286. }
  287.  
  288. async refreshVote() {
  289. this.setLoading(true);
  290. const { score, scores_by_rating, current_vote } = await callApi("status", { token, editorial: this.editorial });
  291. const vote = current_vote == "up" ? 1 : current_vote == "down" ? -1 : 0;
  292. const dist = [0, 0, 0, 0, 0, 0, 0, 0];
  293. for (const [key, value] of Object.entries(scores_by_rating)) {
  294. const rating = parseInt(key.split("-")[0]);
  295. if (rating < 2800) {
  296. dist[Math.trunc(rating / 400)] += value;
  297. } else {
  298. dist[7] += value;
  299. }
  300. }
  301. this.setCurrentVote(score, vote, dist);
  302. this.setLoading(false);
  303. }
  304.  
  305. async setVote(vote) {
  306. this.score += vote - this.vote;
  307. if (vote == 1) {
  308. await sendVote(this.editorial, "up");
  309. } else if (vote == -1) {
  310. await sendVote(this.editorial, "down");
  311. } else {
  312. await sendVote(this.editorial, "none");
  313. }
  314. await this.refreshVote();
  315. }
  316. }
  317.  
  318. // ローディングアニメーションを追加
  319. const css = new CSSStyleSheet();
  320. css.insertRule(`
  321. .glyphicon-refresh-animate {
  322. animation: glyphicon-refresh-animate-spin 1s infinite linear;
  323. }
  324. `);
  325. css.insertRule(`
  326. @keyframes glyphicon-refresh-animate-spin {
  327. from { transform: rotate(0deg); }
  328. to { transform: rotate(360deg); }
  329. }
  330. `);
  331. css.insertRule(`
  332. .editorial-voting-root-component .btn.active {
  333. filter: grayscale(0.5) brightness(0.8);
  334. }
  335. `);
  336. document.adoptedStyleSheets.push(css);
  337.  
  338. const votes = [];
  339. if (/\/editorial$/.test(location.pathname)) {
  340. for (const link of unsafeWindow.document.querySelectorAll("#main-container a[rel=noopener]")) {
  341. const vote = new VoteComponent(link.href);
  342. link.parentElement.insertBefore(vote.element, link);
  343. votes.push(vote);
  344. }
  345. }
  346. if (/\/editorial\/\d+$/.test(location.pathname)) {
  347. const vote = new VoteComponent(location.href);
  348. document.querySelector("#main-container > div.row > div:nth-child(2) > h2").appendChild(vote.element);
  349. votes.push(vote);
  350. }
  351.  
  352. callApi("statuses", { token, editorials: votes.map(v => v.editorial) }).then(res => {
  353. for (let i = 0; i < res.results.length; i++) {
  354. const { score, scores_by_rating, current_vote } = res.results[i];
  355. const vote = current_vote == "up" ? 1 : current_vote == "down" ? -1 : 0;
  356. const dist = [0, 0, 0, 0, 0, 0, 0, 0];
  357. for (const [key, value] of Object.entries(scores_by_rating)) {
  358. const rating = parseInt(key.split("-")[0]);
  359. if (rating < 2800) {
  360. dist[Math.trunc(rating / 400)] += value;
  361. } else {
  362. dist[7] += value;
  363. }
  364. }
  365. votes[i].setCurrentVote(score, vote, dist);
  366. votes[i].setLoading(false);
  367. }
  368. });
  369. })();