├── .eslintrc.json ├── .gitignore ├── .vscode ├── settings.json └── tasks.json ├── LICENSE ├── NOTICE.md ├── README.md ├── img ├── config1.png ├── config2.png ├── overview1.png └── overview2.png ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── components │ ├── difficultyCircle.ts │ ├── setting.html │ └── topcoderLikeCircle.ts ├── main.ts ├── style │ ├── _custom.scss │ └── theme.ts └── utils │ ├── analyzeSubmissions.ts │ ├── getElementsColorizable.ts │ ├── index.ts │ ├── isContestOver.ts │ ├── parser.ts │ └── problemsIndex.ts ├── tsconfig.json └── types ├── atcoder-embedded.d.ts ├── html.d.ts └── scss.d.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "greasemonkey": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 12 | "airbnb", 13 | "plugin:import/errors", 14 | "plugin:import/warnings", 15 | "plugin:import/typescript", 16 | "plugin:prettier/recommended", 17 | "prettier" 18 | ], 19 | "globals": { 20 | "contestScreenName": "readonly", 21 | "endTime": "readonly", 22 | "startTime": "readonly", 23 | "userScreenName": "readonly" 24 | }, 25 | "parser": "@typescript-eslint/parser", 26 | "parserOptions": { 27 | "ecmaVersion": 2021, 28 | "extraFileExtensions": [".html"], 29 | "project": "./tsconfig.json", 30 | "sourceType": "module" 31 | }, 32 | "plugins": ["@typescript-eslint", "import", "html"], 33 | "root": true, 34 | "rules": { 35 | "func-style": ["error", "expression", { "allowArrowFunctions": true }], 36 | "import/extensions": [ 37 | "error", 38 | "ignorePackages", 39 | { 40 | "js": "never", 41 | "jsx": "never", 42 | "ts": "never", 43 | "tsx": "never" 44 | } 45 | ] 46 | }, 47 | "settings": { 48 | "import/resolver": { 49 | "typescript": { "project": "src/" } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | .pnpm-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | .env.production 78 | 79 | # parcel-bundler cache (https://parceljs.org/) 80 | .cache 81 | .parcel-cache 82 | 83 | # Next.js build output 84 | .next 85 | out 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | dist 90 | 91 | # Gatsby files 92 | .cache/ 93 | # Comment in the public line in if your project uses Gatsby and not Next.js 94 | # https://nextjs.org/blog/next-9-1#public-directory-support 95 | # public 96 | 97 | # vuepress build output 98 | .vuepress/dist 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | 112 | # Stores VSCode versions used for testing VSCode extensions 113 | .vscode-test 114 | 115 | # yarn v2 116 | .yarn/cache 117 | .yarn/unplugged 118 | .yarn/build-state.yml 119 | .yarn/install-state.gz 120 | .pnp.* 121 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "task.allowAutomaticTasks": "on" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "bundle:watch", 7 | "group": "build", 8 | "problemMatcher": [], 9 | "label": "npm: bundle:watch", 10 | "detail": "rollup -c -w", 11 | "runOptions": { "runOn": "folderOpen" } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 hotarunw 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /NOTICE.md: -------------------------------------------------------------------------------- 1 | # 使用したソフトウェアのライセンス表示 2 | 3 | ## [kenkoooo/AtCoderProblems: Extend your AtCoder](https://github.com/kenkoooo/AtCoderProblems) 4 | 5 | MIT License 6 | 7 | Copyright (c) 2019 kenkoooo 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | ## atcoder-difficulty-display 4 | 5 | [![GreasyFork](https://img.shields.io/badge/GreasyFork-install-orange)](https://greasyfork.org/ja/scripts/397185-atcoder-difficulty-display) 6 | [![GitHub](https://img.shields.io/badge/GitHub-Repository-green)](https://github.com/hotarunw/atcoder-difficulty-display) 7 | 8 | ![atcoder-difficulty-display](https://raw.githubusercontent.com/hotarunw/atcoder-difficulty-display/master/img/overview1.png) 9 | ![atcoder-difficulty-display](https://raw.githubusercontent.com/hotarunw/atcoder-difficulty-display/master/img/overview2.png) 10 | 11 | [**AtCoder**](https://atcoder.jp/) のページに [**AtCoder Problems**](https://kenkoooo.com/atcoder/) の難易度を表示するユーザースクリプトです。 12 | 13 | ## Description 14 | 15 | AtCoder のページに AtCoder Problems が推定した**難易度**を表示します。 16 | 17 | 難易度が表示されて色付けされます。 18 | 推定不可能な問題の場合は**Unavailable**と表示します。 19 | 実験的手法で推定された難易度には、AtCoder Problems と同様に「🧪」をつけます。 20 | 21 | ## 現在あるバグ 22 | 23 | まれに Difficulty が何も表示されなくなるバグがあります。 24 | 開発者ツールのアプリケーションタブから **ATCODER-PROBLEMS-API-** という名前の IndexedDB データベースを削除すると解消します。 25 | 26 | バグの原因が分からなくて困っています。心当たりあれば Issueに投稿してほしいです。バグ発生時のデータベースのデータもほしいです。 27 | 28 | ## Install 29 | 30 | 1. [**Tampermonkey**](https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo?hl=ja) などのユーザースクリプトマネージャをインストールします。 31 | 2. [![GreasyFork](https://img.shields.io/badge/GreasyFork-install-orange)](https://greasyfork.org/ja/scripts/397185-atcoder-difficulty-display) からユーザースクリプトをインストールします。 32 | 33 | ### ネタバレ防止機能 34 | 35 | ![atcoder-difficulty-display](https://raw.githubusercontent.com/hotarunw/atcoder-difficulty-display/master/img/config1.png) 36 | ![atcoder-difficulty-display](https://raw.githubusercontent.com/hotarunw/atcoder-difficulty-display/master/img/config2.png) 37 | 38 | difficulty のネタバレ防止目的で最初は difficulty を表示せず、ボタンを押すことで difficulty を表示できます。 39 | 40 | [基本設定 \- AtCoder](https://atcoder.jp/settings) の下部にあるネタバレ防止のチェックボックスを ON にすることで有効になります。 41 | 42 | ## Note 43 | 44 | このユーザースクリプトは**AtCoder Problems**の API を使っているのみで、**AtCoder Problems**とは関わりはありません。 45 | 46 | 意見があれば [GitHub リポジトリ](https://github.com/hotarunw/atcoder-difficulty-display) に Issue を立ててください。 47 | 48 | 難易度については [AtCoder Problems の難易度推定について](http://pepsin-amylase.hatenablog.com/entry/atcoder-problems-difficulty) を見てください。 49 | 50 | ## License 51 | 52 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 53 | 54 | Copyright (c) 2020 hotarunw 55 | 56 | This software is released under the MIT License, see LICENSE. 57 | 58 | ### 使用した OSS ソフトウェアのライセンス表示 59 | 60 | [NOTICE](./NOTICE.md) 61 | 62 | 63 | -------------------------------------------------------------------------------- /img/config1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotarunw/atcoder-difficulty-display/b3e5e89f114b1e8c2472085feea7c0c687dbf293/img/config1.png -------------------------------------------------------------------------------- /img/config2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotarunw/atcoder-difficulty-display/b3e5e89f114b1e8c2472085feea7c0c687dbf293/img/config2.png -------------------------------------------------------------------------------- /img/overview1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotarunw/atcoder-difficulty-display/b3e5e89f114b1e8c2472085feea7c0c687dbf293/img/overview1.png -------------------------------------------------------------------------------- /img/overview2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotarunw/atcoder-difficulty-display/b3e5e89f114b1e8c2472085feea7c0c687dbf293/img/overview2.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atcoder-difficulty-display", 3 | "version": "2.0.1", 4 | "description": "AtCoder Problemsの難易度を表示します。", 5 | "typeRoots": [ 6 | "types", 7 | "node_modules/@types" 8 | ], 9 | "type": "module", 10 | "scripts": { 11 | "lint": "eslint --ignore-path .gitignore \"./src/**/*.{js,jsx,ts,tsx}\"", 12 | "lint:fix": "eslint --ignore-path .gitignore \"./src/**/*.{js,jsx,ts,tsx}\" --fix", 13 | "prettier": "prettier --write --ignore-path .gitignore \"**/*.{css,scss,html,js,json,jsx,md,ts,tsx}\"", 14 | "bundle": "rollup -c", 15 | "bundle:watch": "rollup -c -w", 16 | "test": "jest" 17 | }, 18 | "author": "hotarunw", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/hotarunw/atcoder-difficulty-display/issues" 22 | }, 23 | "devDependencies": { 24 | "@rollup/plugin-typescript": "^11.1.5", 25 | "@types/jest": "^29.5.6", 26 | "@types/tampermonkey": "^4.20.4", 27 | "@typescript-eslint/eslint-plugin": "^6.8.0", 28 | "@typescript-eslint/parser": "^6.8.0", 29 | "eslint": "^8.52.0", 30 | "eslint-config-airbnb": "^19.0.4", 31 | "eslint-config-prettier": "^9.0.0", 32 | "eslint-import-resolver-typescript": "^3.6.1", 33 | "eslint-plugin-html": "^7.1.0", 34 | "eslint-plugin-prettier": "^5.0.1", 35 | "jest": "^29.7.0", 36 | "prettier": "^3.0.3", 37 | "rollup": "^4.22.4", 38 | "rollup-plugin-html": "^0.2.1", 39 | "rollup-plugin-node-resolve": "^5.2.0", 40 | "rollup-plugin-scss": "^4.0.0", 41 | "sass": "^1.69.4", 42 | "ts-jest": "^29.1.1", 43 | "typescript": "^5.2.2" 44 | }, 45 | "dependencies": { 46 | "atcoder-problems-api": "github:key-moon/atcoder-problems-api", 47 | "moment-es6": "^1.0.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | /* eslint-disable @typescript-eslint/restrict-template-expressions */ 4 | import typescript from "@rollup/plugin-typescript"; 5 | import html from "rollup-plugin-html"; 6 | import scss from "rollup-plugin-scss"; 7 | import packageJson from "./package.json" assert { type: "json" }; 8 | 9 | const userScriptBanner = ` 10 | // ==UserScript== 11 | // @name ${packageJson.name} 12 | // @namespace https://github.com/hotarunw 13 | // @version ${packageJson.version} 14 | // @description ${packageJson.description} 15 | // @author ${packageJson.author} 16 | // @license ${packageJson.license} 17 | // @supportURL ${packageJson.bugs.url} 18 | // @match https://atcoder.jp/contests/* 19 | // @exclude https://atcoder.jp/contests/ 20 | // @match https://atcoder.jp/settings 21 | // @grant GM_addStyle 22 | // @grant GM_setValue 23 | // @grant GM_getValue 24 | // @require https://greasyfork.org/scripts/437862-atcoder-problems-api/code/atcoder-problems-api.js?version=1004589 25 | // ==/UserScript==`.trim(); 26 | 27 | export default [ 28 | { 29 | input: "src/main.ts", 30 | output: { 31 | banner: userScriptBanner, 32 | file: "dist/dist.js", 33 | }, 34 | plugins: [ 35 | html({ 36 | include: "**/*.html", 37 | }), 38 | scss({ 39 | output: false, 40 | }), 41 | typescript(), 42 | ], 43 | }, 44 | ]; 45 | -------------------------------------------------------------------------------- /src/components/difficultyCircle.ts: -------------------------------------------------------------------------------- 1 | // 次のコードを引用・編集 2 | // [AtCoderProblems/DifficultyCircle\.tsx at master · kenkoooo/AtCoderProblems](https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/components/DifficultyCircle.tsx) 3 | // 0469e07274fda2282c9351c2308ed73880728e95 4 | 5 | import { getRatingColor } from "../utils/problemsIndex"; 6 | import { topcoderLikeCircle } from "./topcoderLikeCircle"; 7 | 8 | const getColor = (difficulty: number) => { 9 | if (difficulty < 3200) return getRatingColor(difficulty); 10 | if (difficulty < 3600) return "Bronze"; 11 | if (difficulty < 4000) return "Silver"; 12 | return "Gold"; 13 | }; 14 | 15 | const difficultyCircle = ( 16 | difficulty: number, 17 | big = true, 18 | extraDescription = "" 19 | ): string => { 20 | if (Number.isNaN(difficulty)) { 21 | // Unavailableの難易度円はProblemsとは異なりGlyphiconの「?」を使用 22 | const className = `glyphicon glyphicon-question-sign aria-hidden='true' 23 | difficulty-unavailable 24 | ${big ? "difficulty-unavailable-icon-big" : "difficulty-unavailable-icon"}`; 25 | const content = "Difficulty is unavailable."; 26 | 27 | return ``; 31 | } 32 | const color = getColor(difficulty); 33 | 34 | return topcoderLikeCircle(color, difficulty, big, extraDescription); 35 | }; 36 | 37 | export default difficultyCircle; 38 | -------------------------------------------------------------------------------- /src/components/setting.html: -------------------------------------------------------------------------------- 1 |

atcoder-difficulty-display

2 |
3 | GitHub 4 |
5 |
6 | 7 |
8 |
9 | 16 |
17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /src/components/topcoderLikeCircle.ts: -------------------------------------------------------------------------------- 1 | // 次のコードを引用・編集 2 | // [AtCoderProblems/TopcoderLikeCircle\.tsx at master · kenkoooo/AtCoderProblems](https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/components/TopcoderLikeCircle.tsx) 3 | // 02d7ed77d8d8a9fa8d32cb9981f18dfe53f2c5f0 4 | 5 | import { Theme, ThemeLight } from "../style/theme"; 6 | import { getRatingColorCode, RatingColor } from "../utils/problemsIndex"; 7 | 8 | // FIXME: ダークテーマ対応 9 | const useTheme = () => ThemeLight; 10 | 11 | type RatingMetalColor = "Bronze" | "Silver" | "Gold"; 12 | const getRatingMetalColorCode = (metalColor: RatingMetalColor) => { 13 | switch (metalColor) { 14 | case "Bronze": 15 | return { base: "#965C2C", highlight: "#FFDABD" }; 16 | case "Silver": 17 | return { base: "#808080", highlight: "white" }; 18 | case "Gold": 19 | return { base: "#FFD700", highlight: "white" }; 20 | default: 21 | return { base: "#FFD700", highlight: "white" }; 22 | } 23 | }; 24 | 25 | type RatingColorWithMetal = RatingColor | RatingMetalColor; 26 | const getStyleOptions = ( 27 | color: RatingColorWithMetal, 28 | fillRatio: number, 29 | theme: Theme 30 | ) => { 31 | if (color === "Bronze" || color === "Silver" || color === "Gold") { 32 | const metalColor = getRatingMetalColorCode(color); 33 | return { 34 | borderColor: metalColor.base, 35 | background: `linear-gradient(to right, \ 36 | ${metalColor.base}, ${metalColor.highlight}, ${metalColor.base})`, 37 | }; 38 | } 39 | const colorCode = getRatingColorCode(color, theme); 40 | return { 41 | borderColor: colorCode, 42 | background: `border-box linear-gradient(to top, \ 43 | ${colorCode} ${fillRatio * 100}%, \ 44 | rgba(0,0,0,0) ${fillRatio * 100}%)`, 45 | }; 46 | }; 47 | 48 | export const topcoderLikeCircle = ( 49 | color: RatingColorWithMetal, 50 | rating: number, 51 | big = true, 52 | extraDescription = "" 53 | ): string => { 54 | const fillRatio = rating >= 3200 ? 1.0 : (rating % 400) / 400; 55 | const className = `topcoder-like-circle 56 | ${big ? "topcoder-like-circle-big" : ""} rating-circle`; 57 | const theme = useTheme(); 58 | const styleOptions = getStyleOptions(color, fillRatio, theme); 59 | const styleOptionsString = `border-color: ${styleOptions.borderColor}; background: ${styleOptions.background};`; 60 | const content = extraDescription 61 | ? `Difficulty: ${extraDescription}` 62 | : `Difficulty: ${rating}`; 63 | // FIXME: TooltipにSolve Prob, Solve Timeを追加 64 | return ``; 68 | }; 69 | 70 | export default topcoderLikeCircle; 71 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | getEstimatedDifficulties, 3 | getProblems, 4 | } from "atcoder-problems-api/information"; 5 | import type { getSubmissions } from "atcoder-problems-api/submission"; 6 | import { 7 | addTypical90Difficulty, 8 | backwardCompatibleProcessing, 9 | hideDifficultyID, 10 | } from "./utils"; 11 | // HACK: もっとスマートに呼ぶ方法はある? 12 | // atcoder-problems-apiをバンドルせずに型だけ呼び出す 13 | // ユーザースクリプトの@requireで呼ぶためバンドルは不要 14 | import difficultyCircle from "./components/difficultyCircle"; 15 | import html from "./components/setting.html"; 16 | import css from "./style/_custom.scss"; 17 | import { 18 | analyzeSubmissions, 19 | generateFirstAcTime, 20 | generatePenaltiesCount, 21 | generateScoreSpan, 22 | generateStatusLabel, 23 | } from "./utils/analyzeSubmissions"; 24 | import { 25 | getElementOfProblemStatus, 26 | getElementsColorizable, 27 | } from "./utils/getElementsColorizable"; 28 | import isContestOver from "./utils/isContestOver"; 29 | import { URL, taskID } from "./utils/parser"; 30 | import { clipDifficulty, getRatingColorClass } from "./utils/problemsIndex"; 31 | 32 | /** 33 | * コンテストページ の処理 \ 34 | * メインの処理 35 | */ 36 | const contestPageProcess = async () => { 37 | // コンテスト終了前は不要なので無効化する 38 | if (!isContestOver()) return; 39 | 40 | // FIXME: ダークテーマ対応 41 | GM_addStyle(css); 42 | 43 | /** 問題一覧取得 */ 44 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 45 | // @ts-ignore 46 | const problems = await getProblems(); 47 | 48 | /** 難易度取得 */ 49 | const problemModels = addTypical90Difficulty( 50 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 51 | // @ts-ignore 52 | await getEstimatedDifficulties(), 53 | problems 54 | ); 55 | // FIXME: PAST対応 56 | // FIXME: JOI非公式難易度表対応 57 | 58 | /** 提出状況取得 */ 59 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 60 | // @ts-ignore 61 | const submissions = await getSubmissions(userScreenName); 62 | 63 | // 色付け対象の要素の配列を取得する 64 | // 難易度が無いものを除く 65 | const elementsColorizable = getElementsColorizable().filter( 66 | (element) => element.taskID in problemModels 67 | ); 68 | 69 | // 問題ステータス(個別の問題ページの実行時間制限とメモリ制限が書かれた部分)を取得する 70 | const elementProblemStatus = getElementOfProblemStatus(); 71 | 72 | /** 73 | * 色付け処理を実行する 74 | */ 75 | const colorizeElement = () => { 76 | // 問題見出し、問題リンクを色付け 77 | elementsColorizable.forEach((element) => { 78 | const model = problemModels[element.taskID]; 79 | // 難易度がUnavailableならばdifficultyプロパティが無い 80 | // difficultyの値をNaNとする 81 | const difficulty = clipDifficulty(model?.difficulty ?? NaN); 82 | // 色付け 83 | if (!Number.isNaN(difficulty)) { 84 | const color = getRatingColorClass(difficulty); 85 | // eslint-disable-next-line no-param-reassign 86 | element.element.classList.add(color); 87 | } else { 88 | element.element.classList.add("difficulty-unavailable"); 89 | } 90 | 91 | // 🧪追加 92 | if (model?.is_experimental) { 93 | element.element.insertAdjacentText("afterbegin", "🧪"); 94 | } 95 | 96 | // ◒難易度円追加 97 | element.element.insertAdjacentHTML( 98 | element.afterbegin ? "afterbegin" : "beforebegin", 99 | difficultyCircle(difficulty, element.big, model?.extra_difficulty) 100 | ); 101 | }); 102 | 103 | // 個別の問題ページのところに難易度等情報を追加 104 | if (elementProblemStatus) { 105 | // 難易度の値を表示する 106 | 107 | // 難易度推定の対象外なら、この値はundefined 108 | const model = problemModels[taskID]; 109 | 110 | // 難易度がUnavailableのときはdifficultyの値をNaNとする 111 | // 難易度がUnavailableならばdifficultyプロパティが無い 112 | const difficulty = clipDifficulty(model?.difficulty ?? NaN); 113 | 114 | // 色付け 115 | let className = ""; 116 | if (difficulty) { 117 | className = getRatingColorClass(difficulty); 118 | } else if (model) { 119 | className = "difficulty-unavailable"; 120 | } else { 121 | className = ""; 122 | } 123 | 124 | // Difficultyの値設定 125 | let value = ""; 126 | if (difficulty) { 127 | value = difficulty.toString(); 128 | } else if (model) { 129 | value = "Unavailable"; 130 | } else { 131 | value = "None"; 132 | } 133 | // 🧪追加 134 | const experimentalText = model?.is_experimental ? "🧪" : ""; 135 | 136 | const content = `${experimentalText}${value}`; 137 | 138 | elementProblemStatus.insertAdjacentHTML( 139 | "beforeend", 140 | ` / Difficulty: 141 | ${content}` 142 | ); 143 | 144 | /** この問題への提出 提出時間ソート済みと想定 */ 145 | const thisTaskSubmissions = submissions.filter( 146 | (element) => element.problem_id === taskID 147 | ); 148 | const analyze = analyzeSubmissions(thisTaskSubmissions); 149 | 150 | // コンテスト前中後外の提出状況 コンテスト中の解答時間とペナルティ数を表示する 151 | let statuesHTML = ""; 152 | statuesHTML += generateStatusLabel( 153 | analyze.before.representative, 154 | "before" 155 | ); 156 | statuesHTML += generateStatusLabel( 157 | analyze.during.representative, 158 | "during" 159 | ); 160 | statuesHTML += generateStatusLabel(analyze.after.representative, "after"); 161 | statuesHTML += generateStatusLabel( 162 | analyze.another.representative, 163 | "another" 164 | ); 165 | statuesHTML += generatePenaltiesCount(analyze.during.penalties); 166 | statuesHTML += generateFirstAcTime(analyze.during.firstAc); 167 | 168 | if (statuesHTML.length > 0) { 169 | elementProblemStatus.insertAdjacentHTML( 170 | "beforeend", 171 | ` / Status: ${statuesHTML}` 172 | ); 173 | } 174 | 175 | // コンテスト前中後外の1万点以上の最大得点を表示する 176 | // NOTE: マラソン用のため、1万点以上とした 177 | let scoresHTML = ""; 178 | scoresHTML += generateScoreSpan(analyze.before.maxScore, "before"); 179 | scoresHTML += generateScoreSpan(analyze.during.maxScore, "during"); 180 | scoresHTML += generateScoreSpan(analyze.after.maxScore, "after"); 181 | scoresHTML += generateScoreSpan(analyze.another.maxScore, "another"); 182 | 183 | if (scoresHTML.length > 0) { 184 | elementProblemStatus.insertAdjacentHTML( 185 | "beforeend", 186 | ` / Scores: ${scoresHTML}` 187 | ); 188 | } 189 | } 190 | 191 | // bootstrap3のtooltipを有効化 難易度円の値を表示するtooltip 192 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 193 | // @ts-ignore 194 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, no-undef 195 | $('[data-toggle="tooltip"]').tooltip(); 196 | }; 197 | 198 | // 色付け処理実行 199 | if (!GM_getValue(hideDifficultyID, false)) { 200 | // 設定 ネタバレ防止がOFFなら何もせず実行 201 | colorizeElement(); 202 | } else { 203 | // 設定 ネタバレ防止がONなら 204 | // ページ上部にボタンを追加 押すと色付け処理が実行される 205 | const place = 206 | document.getElementsByTagName("h2")[0] ?? 207 | document.getElementsByClassName("h2")[0] ?? 208 | undefined; 209 | if (place) { 210 | place.insertAdjacentHTML( 211 | "beforebegin", 212 | `` 214 | ); 215 | 216 | const button = document.getElementById(hideDifficultyID); 217 | 218 | if (button) { 219 | button.addEventListener("click", () => { 220 | button.style.display = "none"; 221 | colorizeElement(); 222 | }); 223 | } 224 | } 225 | } 226 | }; 227 | 228 | /** 229 | * 設定ページ の処理 \ 230 | * 設定ボタンを追加する 231 | */ 232 | const settingPageProcess = () => { 233 | const insertion = document.getElementsByClassName("form-horizontal")[0]; 234 | if (insertion === undefined) return; 235 | 236 | insertion.insertAdjacentHTML("afterend", html); 237 | 238 | // 設定 ネタバレ防止のチェックボックスの読み込み 切り替え 保存処理を追加 239 | const hideDifficultyChechbox = document.getElementById(hideDifficultyID); 240 | if ( 241 | hideDifficultyChechbox && 242 | hideDifficultyChechbox instanceof HTMLInputElement 243 | ) { 244 | hideDifficultyChechbox.checked = GM_getValue(hideDifficultyID, false); 245 | hideDifficultyChechbox.addEventListener("change", () => { 246 | GM_setValue(hideDifficultyID, hideDifficultyChechbox.checked); 247 | }); 248 | } 249 | }; 250 | 251 | /** 252 | * 最初に実行される部分 \ 253 | * 共通の処理を実行した後ページごとの処理を実行する 254 | */ 255 | (async () => { 256 | // 共通の処理 257 | backwardCompatibleProcessing(); 258 | 259 | // ページ別の処理 260 | if (URL[3] === "contests" && URL.length >= 5) { 261 | await contestPageProcess(); 262 | } 263 | if (URL[3] === "settings" && URL.length === 4) { 264 | settingPageProcess(); 265 | } 266 | })().catch((error) => { 267 | // eslint-disable-next-line no-console 268 | console.error("[atcoder-difficulty-display]", error); 269 | }); 270 | -------------------------------------------------------------------------------- /src/style/_custom.scss: -------------------------------------------------------------------------------- 1 | // 次のコードを引用・編集 2 | // [AtCoderProblems/\_custom\.scss at master · kenkoooo/AtCoderProblems](https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/style/_custom.scss) 3 | // fe2824f4e9954b66408ac9c2c677d075bcbb2c03 4 | 5 | // Color Variables 6 | 7 | $difficulty-grey-color: #808080 !default; 8 | $difficulty-brown-color: #804000 !default; 9 | $difficulty-green-color: #008000 !default; 10 | $difficulty-cyan-color: #00c0c0 !default; 11 | $difficulty-blue-color: #0000ff !default; 12 | $difficulty-yellow-color: #c0c000 !default; 13 | $difficulty-orange-color: #ff8000 !default; 14 | $difficulty-red-color: #ff0000 !default; 15 | 16 | $success-bg-color: #c3e6cb !default; 17 | $success-hover-bg-color: #b1dfbb !default; 18 | 19 | $success-intime-bg-color: #9ad59e !default; 20 | $success-intime-hover-bg-color: #88cc88 !default; 21 | 22 | $success-before-contest-bg-color: #99ccff !default; 23 | $success-before-contest-hover-bg-color: #88bbff !default; 24 | 25 | $warning-bg-color: #ffeeba !default; 26 | $warning-hover-bg-color: #ffe8a1 !default; 27 | 28 | $warning-intime-bg-color: #ffdd99 !default; 29 | $warning-intime-hover-bg-color: #f0cc99 !default; 30 | 31 | $danger-bg-color: #f5c6cb !default; 32 | $danger-hover-bg-color: #f1b0b7 !default; 33 | 34 | $penalties-color: #ff0000 !default; 35 | 36 | .difficulty-red { 37 | color: $difficulty-red-color; 38 | } 39 | .difficulty-orange { 40 | color: $difficulty-orange-color; 41 | } 42 | .difficulty-yellow { 43 | color: $difficulty-yellow-color; 44 | } 45 | .difficulty-blue { 46 | color: $difficulty-blue-color; 47 | } 48 | .difficulty-cyan { 49 | color: $difficulty-cyan-color; 50 | } 51 | .difficulty-green { 52 | color: $difficulty-green-color; 53 | } 54 | .difficulty-brown { 55 | color: $difficulty-brown-color; 56 | } 57 | .difficulty-grey { 58 | color: $difficulty-grey-color; 59 | } 60 | 61 | .topcoder-like-circle { 62 | display: block; 63 | border-radius: 50%; 64 | border-style: solid; 65 | border-width: 1px; 66 | width: 12px; 67 | height: 12px; 68 | } 69 | 70 | .topcoder-like-circle-big { 71 | border-width: 3px; 72 | width: 36px; 73 | height: 36px; 74 | } 75 | 76 | .rating-circle { 77 | margin-right: 5px; 78 | display: inline-block; 79 | } 80 | 81 | // AtCoderDifficultyDisplayで追加 82 | $difficulty-unavailable-color: #17a2b8 !default; 83 | 84 | .difficulty-unavailable { 85 | color: $difficulty-unavailable-color; 86 | } 87 | 88 | .difficulty-unavailable-icon { 89 | margin-right: 0.3px; 90 | } 91 | 92 | .difficulty-unavailable-icon-big { 93 | font-size: 36px; 94 | margin-right: 5px; 95 | } 96 | 97 | .label-status-a { 98 | color: white; 99 | } 100 | 101 | .label-success-after-contest { 102 | background-color: $success-intime-bg-color; 103 | } 104 | 105 | .label-warning-after-contest { 106 | background-color: $warning-intime-bg-color; 107 | } 108 | -------------------------------------------------------------------------------- /src/style/theme.ts: -------------------------------------------------------------------------------- 1 | // 次のコードを引用 2 | // [AtCoderProblems/theme\.ts at master · kenkoooo/AtCoderProblems](https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/style/theme.ts) 3 | // 8b1b86c740e627e59abf056a11c00582e12b30ff 4 | 5 | export const ThemeLight = { 6 | difficultyBlackColor: "#404040", 7 | difficultyGreyColor: "#808080", 8 | difficultyBrownColor: "#804000", 9 | difficultyGreenColor: "#008000", 10 | difficultyCyanColor: "#00C0C0", 11 | difficultyBlueColor: "#0000FF", 12 | difficultyYellowColor: "#C0C000", 13 | difficultyOrangeColor: "#FF8000", 14 | difficultyRedColor: "#FF0000", 15 | }; 16 | 17 | export const ThemeDark: typeof ThemeLight = { 18 | ...ThemeLight, 19 | difficultyBlackColor: "#FFFFFF", 20 | difficultyGreyColor: "#C0C0C0", 21 | difficultyBrownColor: "#B08C56", 22 | difficultyGreenColor: "#3FAF3F", 23 | difficultyCyanColor: "#42E0E0", 24 | difficultyBlueColor: "#8888FF", 25 | difficultyYellowColor: "#FFFF56", 26 | difficultyOrangeColor: "#FFB836", 27 | difficultyRedColor: "#FF6767", 28 | }; 29 | 30 | export const ThemePurple = ThemeLight; 31 | 32 | export type Theme = typeof ThemeLight; 33 | -------------------------------------------------------------------------------- /src/utils/analyzeSubmissions.ts: -------------------------------------------------------------------------------- 1 | import { nonPenaltyJudge, SubmissionEntry } from "./index"; 2 | import { contestID, taskID } from "./parser"; 3 | 4 | export type analyzeReturn = { 5 | before: { 6 | maxScore: SubmissionEntry | undefined; 7 | firstAc: SubmissionEntry | undefined; 8 | representative: SubmissionEntry | undefined; 9 | }; 10 | during: { 11 | maxScore: SubmissionEntry | undefined; 12 | firstAc: SubmissionEntry | undefined; 13 | representative: SubmissionEntry | undefined; 14 | penalties: number; 15 | }; 16 | after: { 17 | maxScore: SubmissionEntry | undefined; 18 | firstAc: SubmissionEntry | undefined; 19 | representative: SubmissionEntry | undefined; 20 | }; 21 | another: { 22 | maxScore: SubmissionEntry | undefined; 23 | firstAc: SubmissionEntry | undefined; 24 | representative: SubmissionEntry | undefined; 25 | }; 26 | }; 27 | 28 | /** 29 | * 得点が最大の提出を返す 30 | */ 31 | const parseMaxScore = (submissionsArg: SubmissionEntry[]) => { 32 | if (submissionsArg.length === 0) { 33 | return undefined; 34 | } 35 | const maxScore = submissionsArg.reduce((left, right) => 36 | left.point > right.point ? left : right 37 | ); 38 | return maxScore; 39 | }; 40 | 41 | /** 42 | * ペナルティ数を数える 43 | */ 44 | const parsePenalties = (submissionsArg: SubmissionEntry[]) => { 45 | let penalties = 0; 46 | let hasAccepted = false; 47 | submissionsArg.forEach((element) => { 48 | hasAccepted = element.result === "AC" || hasAccepted; 49 | if (!hasAccepted && !nonPenaltyJudge.includes(element.result)) { 50 | penalties += 1; 51 | } 52 | }); 53 | 54 | return penalties; 55 | }; 56 | 57 | /** 58 | * 最初にACした提出を返す 59 | */ 60 | const parseFirstAcceptedTime = (submissionsArg: SubmissionEntry[]) => { 61 | const ac = submissionsArg.filter((element) => element.result === "AC"); 62 | return ac[0]; 63 | }; 64 | 65 | /** 66 | * 代表的な提出を返す 67 | * 1. 最後にACした提出 68 | * 2. 最後の提出 69 | * 3. undefined 70 | */ 71 | const parseRepresentativeSubmission = (submissionsArg: SubmissionEntry[]) => { 72 | const ac = submissionsArg.filter((element) => element.result === "AC"); 73 | const nonAC = submissionsArg.filter((element) => element.result !== "AC"); 74 | 75 | if (ac.length > 0) return ac.slice(-1)[0]; 76 | if (nonAC.length > 0) return nonAC.slice(-1)[0]; 77 | return undefined; 78 | }; 79 | 80 | /** 81 | * 提出をパースして情報を返す 82 | * 対象: コンテスト前,中,後の提出 別コンテストの同じ問題への提出 83 | * 返す情報: 得点が最大の提出 最初のACの提出 代表的な提出 ペナルティ数 84 | */ 85 | export const analyzeSubmissions = ( 86 | submissionsArg: SubmissionEntry[] 87 | ): analyzeReturn => { 88 | const submissions = submissionsArg.filter( 89 | (element) => element.problem_id === taskID 90 | ); 91 | 92 | const beforeContest = submissions.filter( 93 | (element) => 94 | element.contest_id === contestID && 95 | element.epoch_second < startTime.unix() 96 | ); 97 | 98 | const duringContest = submissions.filter( 99 | (element) => 100 | element.contest_id === contestID && 101 | element.epoch_second >= startTime.unix() && 102 | element.epoch_second < endTime.unix() 103 | ); 104 | 105 | const afterContest = submissions.filter( 106 | (element) => 107 | element.contest_id === contestID && element.epoch_second >= endTime.unix() 108 | ); 109 | 110 | const anotherContest = submissions.filter( 111 | (element) => element.contest_id !== contestID 112 | ); 113 | 114 | return { 115 | before: { 116 | maxScore: parseMaxScore(beforeContest), 117 | firstAc: parseFirstAcceptedTime(beforeContest), 118 | representative: parseRepresentativeSubmission(beforeContest), 119 | }, 120 | during: { 121 | maxScore: parseMaxScore(duringContest), 122 | firstAc: parseFirstAcceptedTime(duringContest), 123 | representative: parseRepresentativeSubmission(duringContest), 124 | penalties: parsePenalties(duringContest), 125 | }, 126 | after: { 127 | maxScore: parseMaxScore(afterContest), 128 | firstAc: parseFirstAcceptedTime(afterContest), 129 | representative: parseRepresentativeSubmission(afterContest), 130 | }, 131 | another: { 132 | maxScore: parseMaxScore(anotherContest), 133 | firstAc: parseFirstAcceptedTime(anotherContest), 134 | representative: parseRepresentativeSubmission(anotherContest), 135 | }, 136 | }; 137 | }; 138 | 139 | export type Period = "before" | "during" | "after" | "another"; 140 | 141 | /** 142 | * 提出状況を表すラベルを生成 143 | */ 144 | export const generateStatusLabel = ( 145 | submission: SubmissionEntry | undefined, 146 | type: Period 147 | ) => { 148 | if (submission === undefined) { 149 | return ""; 150 | } 151 | 152 | const isAC = submission.result === "AC"; 153 | 154 | let className = ""; 155 | switch (type) { 156 | case "before": 157 | className = "label-primary"; 158 | break; 159 | case "during": 160 | className = isAC ? "label-success" : "label-warning"; 161 | break; 162 | case "after": 163 | className = isAC 164 | ? "label-success-after-contest" 165 | : "label-warning-after-contest"; 166 | break; 167 | case "another": 168 | className = "label-default"; 169 | break; 170 | default: 171 | break; 172 | } 173 | 174 | let content = ""; 175 | switch (type) { 176 | case "before": 177 | content = "コンテスト前の提出"; 178 | break; 179 | case "during": 180 | content = "コンテスト中の提出"; 181 | break; 182 | case "after": 183 | content = "コンテスト後の提出"; 184 | break; 185 | case "another": 186 | content = "別コンテストの同じ問題への提出"; 187 | break; 188 | default: 189 | break; 190 | } 191 | 192 | const href = `https://atcoder.jp/contests/${submission.contest_id}/submissions/${submission.id}`; 193 | 194 | return ` 196 | ${submission.result} 197 | `; 198 | }; 199 | 200 | /** 201 | * ペナルティ数を表示 202 | */ 203 | export const generatePenaltiesCount = (penalties: number) => { 204 | if (penalties <= 0) { 205 | return ""; 206 | } 207 | 208 | const content = "コンテスト中のペナルティ数"; 209 | 210 | return ` 211 | (${penalties.toString()}) 212 | `; 213 | }; 214 | 215 | /** 216 | * 最初のACの時間を表示 217 | */ 218 | export const generateFirstAcTime = ( 219 | submission: SubmissionEntry | undefined 220 | ) => { 221 | if (submission === undefined) { 222 | return ""; 223 | } 224 | 225 | const content = "提出時間"; 226 | 227 | const href = `https://atcoder.jp/contests/${submission.contest_id}/submissions/${submission.id}`; 228 | 229 | const elapsed = submission.epoch_second - startTime.unix(); 230 | const elapsedSeconds = elapsed % 60; 231 | const elapsedMinutes = Math.trunc(elapsed / 60); 232 | 233 | return ` 234 | 235 | ${elapsedMinutes}:${elapsedSeconds} 236 | 237 | `; 238 | }; 239 | 240 | /** 241 | * マラソン用に得点を表示するスパンを生成 242 | */ 243 | export const generateScoreSpan = ( 244 | submission: SubmissionEntry | undefined, 245 | type: Period 246 | ) => { 247 | if (submission === undefined) { 248 | return ""; 249 | } 250 | 251 | // マラソン用を考えているのでとりあえず1万点未満は表示しない 252 | if (submission.point < 10000) { 253 | return ""; 254 | } 255 | 256 | let className = ""; 257 | switch (type) { 258 | case "before": 259 | className = "difficulty-blue"; 260 | break; 261 | case "during": 262 | className = "difficulty-green"; 263 | break; 264 | case "after": 265 | className = "difficulty-yellow"; 266 | break; 267 | case "another": 268 | className = "difficulty-grey"; 269 | break; 270 | default: 271 | break; 272 | } 273 | 274 | let content = ""; 275 | switch (type) { 276 | case "before": 277 | content = "コンテスト前の提出"; 278 | break; 279 | case "during": 280 | content = "コンテスト中の提出"; 281 | break; 282 | case "after": 283 | content = "コンテスト後の提出"; 284 | break; 285 | case "another": 286 | content = "別コンテストの同じ問題への提出"; 287 | break; 288 | default: 289 | break; 290 | } 291 | 292 | const href = `https://atcoder.jp/contests/${submission.contest_id}/submissions/${submission.id}`; 293 | 294 | return ` 296 | 297 | ${submission.point} 298 | 299 | `; 300 | }; 301 | -------------------------------------------------------------------------------- /src/utils/getElementsColorizable.ts: -------------------------------------------------------------------------------- 1 | import { pageType, parseURL, taskID } from "./parser"; 2 | 3 | export type ElementAndId = { 4 | element: HTMLElement; 5 | taskID: string; 6 | big?: boolean; 7 | afterbegin?: boolean; 8 | }; 9 | 10 | /** 11 | * 色付け対象の要素の配列を取得する 12 | * * 個別の問題ページのタイトル 13 | * * 問題へのリンク 14 | * * 解説ページのH3の問題名 15 | */ 16 | export const getElementsColorizable = (): ElementAndId[] => { 17 | const elementsColorizable: ElementAndId[] = []; 18 | 19 | // 問題ページのタイトル 20 | if (pageType === "task") { 21 | const element = document.getElementsByClassName("h2")[0] as HTMLElement; 22 | if (element) { 23 | elementsColorizable.push({ element, taskID, big: true }); 24 | } 25 | } 26 | 27 | // aタグ要素 問題ページ、提出ページ等のリンクを想定 28 | const aTagsRaw = document.getElementsByTagName("a"); 29 | let aTagsArray = Array.prototype.slice.call(aTagsRaw) as HTMLAnchorElement[]; 30 | // 問題ページの一番左の要素は除く 見た目の問題です 31 | aTagsArray = aTagsArray.filter( 32 | (element) => 33 | !( 34 | (pageType === "tasks" || pageType === "score") && 35 | !element.parentElement?.previousElementSibling 36 | ) 37 | ); 38 | 39 | // 左上の日本語/英語切り替えリンクは除く 40 | aTagsArray = aTagsArray.filter((element) => !element.href.includes("?lang=")); 41 | 42 | // 解説ページの問題名の右のリンクは除く 43 | aTagsArray = aTagsArray.filter( 44 | (element) => 45 | !( 46 | pageType === "editorial" && 47 | element.children[0]?.classList.contains("glyphicon-new-window") 48 | ) 49 | ); 50 | 51 | const aTagsConverted: ElementAndId[] = aTagsArray.map((element) => { 52 | const url = parseURL(element.href); 53 | 54 | const taskIDFromURL = 55 | (url[url.length - 2] ?? "") === "tasks" ? url[url.length - 1] ?? "" : ""; 56 | // 個別の解説ページではbig 57 | const big = element.parentElement?.tagName.includes("H2") ?? false; 58 | 59 | // Comfortable AtCoderのドロップダウンではafterbegin 60 | const afterbegin = 61 | element.parentElement?.parentElement?.classList.contains( 62 | "dropdown-menu" 63 | ) ?? false; 64 | return { element, taskID: taskIDFromURL, big, afterbegin }; 65 | }); 66 | 67 | elementsColorizable.push(...aTagsConverted); 68 | 69 | // h3タグ要素 解説ページの問題名を想定 70 | const h3TagsRaw = document.getElementsByTagName("h3"); 71 | const h3TagsArray = Array.prototype.slice.call( 72 | h3TagsRaw 73 | ) as HTMLAnchorElement[]; 74 | const h3TagsConverted: ElementAndId[] = h3TagsArray.map((element) => { 75 | const url = parseURL(element.getElementsByTagName("a")[0]?.href ?? ""); 76 | 77 | const taskIDFromURL = 78 | (url[url.length - 2] ?? "") === "tasks" ? url[url.length - 1] ?? "" : ""; 79 | return { element, taskID: taskIDFromURL, big: true, afterbegin: true }; 80 | }); 81 | 82 | // FIXME: 別ユーザースクリプトが指定した要素を色付けする機能 83 | // 指定したクラスがあれば対象とすることを考えている 84 | // ユーザースクリプトの実行順はユーザースクリプトマネージャーの設定で変更可能 85 | 86 | elementsColorizable.push(...h3TagsConverted); 87 | 88 | return elementsColorizable; 89 | }; 90 | 91 | /** 92 | * 問題ステータス(実行時間制限とメモリ制限が書かれた部分)のHTMLオブジェクトを取得 93 | */ 94 | export const getElementOfProblemStatus = () => { 95 | if (pageType !== "task") return undefined; 96 | const psRaw = document 97 | ?.getElementById("main-container") 98 | ?.getElementsByTagName("p"); 99 | const ps = Array.prototype.slice.call(psRaw) as HTMLParagraphElement[]; 100 | if (!psRaw) return undefined; 101 | 102 | const problemStatuses = ps.filter((p) => { 103 | return ( 104 | p.textContent?.includes("メモリ制限") || 105 | p.textContent?.includes("Memory Limit") 106 | ); 107 | }); 108 | return problemStatuses[0]; 109 | }; 110 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { getEstimatedDifficulties } from "atcoder-problems-api/information"; 2 | import type { getSubmissions } from "atcoder-problems-api/submission"; 3 | import { Problem } from "atcoder-problems-api/types"; 4 | 5 | export type SubmissionEntry = (ReturnType< 6 | typeof getSubmissions 7 | > extends Promise 8 | ? P 9 | : never)[number]; 10 | 11 | export type ProblemModel = (ReturnType< 12 | typeof getEstimatedDifficulties 13 | > extends Promise 14 | ? P 15 | : never)[number]; 16 | 17 | export type ProblemModelEx = ProblemModel & { 18 | /** Difficultyの代わりに表示する説明 */ 19 | extra_difficulty?: string; 20 | }; 21 | 22 | export const nonPenaltyJudge = ["AC", "CE", "IE", "WJ", "WR"]; 23 | 24 | /** 設定 ネタバレ防止のID, Key */ 25 | export const hideDifficultyID = "hide-difficulty-atcoder-difficulty-display"; 26 | 27 | /** 28 | * 後方互換処理 29 | */ 30 | export const backwardCompatibleProcessing = () => { 31 | const oldLocalStorageKeys = [ 32 | "atcoderDifficultyDisplayUserSubmissions", 33 | "atcoderDifficultyDisplayUserSubmissionslastFetchedAt", 34 | "atcoderDifficultyDisplayEstimatedDifficulties", 35 | "atcoderDifficultyDisplayEstimatedDifficultieslastFetchedAt", 36 | ]; 37 | 38 | /** 過去バージョンのlocalStorageデータを削除する */ 39 | oldLocalStorageKeys.forEach((key) => { 40 | localStorage.removeItem(key); 41 | }); 42 | }; 43 | 44 | const getTypical90Difficulty = (title: string): number => { 45 | if (title.includes("★1")) return 149; 46 | if (title.includes("★2")) return 399; 47 | if (title.includes("★3")) return 799; 48 | if (title.includes("★4")) return 1199; 49 | if (title.includes("★5")) return 1599; 50 | if (title.includes("★6")) return 1999; 51 | if (title.includes("★7")) return 2399; 52 | return NaN; 53 | }; 54 | 55 | const getTypical90Description = (title: string): string => { 56 | if (title.includes("★1")) return "200 点問題レベル"; 57 | if (title.includes("★2")) return "300 点問題レベル"; 58 | if (title.includes("★3")) return ""; 59 | if (title.includes("★4")) return "400 点問題レベル"; 60 | if (title.includes("★5")) return "500 点問題レベル"; 61 | if (title.includes("★6")) return "これが安定して解ければ上級者です"; 62 | if (title.includes("★7")) return "チャレンジ問題枠です"; 63 | return "エラー: 競プロ典型 90 問の難易度読み取りに失敗しました"; 64 | }; 65 | 66 | export const addTypical90Difficulty = ( 67 | problemModels: { [key: string]: ProblemModel }, 68 | problems: Problem[] 69 | ): { [key: string]: ProblemModelEx } => { 70 | const models: { [key: string]: ProblemModelEx } = problemModels; 71 | const problemsT90 = problems.filter( 72 | (element) => element.contest_id === "typical90" 73 | ); 74 | problemsT90.forEach((element) => { 75 | const difficulty = getTypical90Difficulty(element.title); 76 | 77 | const model: ProblemModelEx = { 78 | slope: NaN, 79 | intercept: NaN, 80 | variance: NaN, 81 | difficulty, 82 | discrimination: NaN, 83 | irt_loglikelihood: NaN, 84 | irt_users: NaN, 85 | is_experimental: false, 86 | extra_difficulty: `${getTypical90Description(element.title)}`, 87 | }; 88 | models[element.id] = model; 89 | }); 90 | return models; 91 | }; 92 | -------------------------------------------------------------------------------- /src/utils/isContestOver.ts: -------------------------------------------------------------------------------- 1 | import { contestID, URL } from "./parser"; 2 | 3 | /** 常設コンテストID一覧 */ 4 | const permanentContestIDs = [ 5 | "practice", 6 | "APG4b", 7 | "abs", 8 | "practice2", 9 | "typical90", 10 | "math-and-algorithm", 11 | "tessoku-book", 12 | ]; 13 | 14 | // FIXME: FIXME: Problemsでデータ取れなかったらコンテストが終了していない判定で良さそう 15 | /** 16 | * 開いているページのコンテストが終了していればtrue \ 17 | * 例外処理として以下の場合もtrueを返す 18 | * * コンテストが常設コンテスト 19 | * * コンテストのページ以外にいる 20 | */ 21 | export default () => { 22 | if (!(URL[3] === "contests" && URL.length >= 5)) return true; 23 | if (permanentContestIDs.includes(contestID)) return true; 24 | return Date.now() > endTime.valueOf(); 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/parser.ts: -------------------------------------------------------------------------------- 1 | // AtCoderの問題ページをパースする 2 | 3 | /** 4 | * URLをパースする パラメータを消す \ 5 | * 例: in: https://atcoder.jp/contests/abc210?lang=en \ 6 | * 例: out: (5)['https:', '', 'atcoder.jp', 'contests', 'abc210'] 7 | */ 8 | export const parseURL = (url: string) => { 9 | // 区切り文字`/`で分割する 10 | // ?以降の文字列を削除してパラメータを削除する 11 | return url.split("/").map((x) => x.replace(/\?.*/i, "")); 12 | }; 13 | 14 | export const URL = parseURL(window.location.href); 15 | 16 | /** 17 | * 表セル要素から、前の要素のテキストが引数と一致する要素を探す 18 | * 個別の提出ページで使うことを想定 19 | * 例: searchSubmissionInfo(["問題", "Task"]) 20 | */ 21 | const searchSubmissionInfo = (key: string[]) => { 22 | const tdTags = document.getElementsByTagName("td"); 23 | const tdTagsArray = Array.prototype.slice.call( 24 | tdTags 25 | ) as HTMLTableCellElement[]; 26 | 27 | return tdTagsArray.filter((elem) => { 28 | const prevElem = elem.previousElementSibling; 29 | const text = prevElem?.textContent; 30 | if (typeof text === "string") return key.includes(text); 31 | return false; 32 | })[0]; 33 | }; 34 | 35 | /** コンテストタイトル 例: AtCoder Beginner Contest 210 */ 36 | export const contestTitle = 37 | document.getElementsByClassName("contest-title")[0]?.textContent ?? ""; 38 | 39 | /** コンテストID 例: abc210 */ 40 | export const contestID = URL[4] ?? ""; 41 | 42 | /** 43 | * ページ種類 \ 44 | * 基本的にコンテストIDの次のパス 45 | * ### 例外 46 | * 個別の問題: task 47 | * 個別の提出: submission 48 | * 個別の問題ページで解説ボタンを押すと遷移する個別の問題の解説一覧ページ: task_editorial 49 | */ 50 | export const pageType = ((): string => { 51 | if (URL.length < 6) return ""; 52 | if (URL.length >= 7 && URL[5] === "submissions" && URL[6] !== "me") 53 | return "submission"; 54 | if (URL.length >= 8 && URL[5] === "tasks" && URL[7] === "editorial") 55 | return "task_editorial"; 56 | if (URL.length >= 7 && URL[5] === "tasks") return "task"; 57 | return URL[5] ?? ""; 58 | })(); 59 | 60 | /** 問題ID 例: abc210_a */ 61 | export const taskID = ((): string => { 62 | if (pageType === "task") { 63 | // 問題ページでは、URLから問題IDを取り出す 64 | return URL[6] ?? ""; 65 | } 66 | if (pageType === "submission") { 67 | // 個別の提出ページでは、問題リンクのURLから問題IDを取り出す 68 | // 提出情報の問題のURLを取得する 69 | const taskCell = searchSubmissionInfo(["問題", "Task"]); 70 | if (!taskCell) return ""; 71 | const taskLink = taskCell.getElementsByTagName("a")[0]; 72 | if (!taskLink) return ""; 73 | const taskUrl = parseURL(taskLink.href); 74 | 75 | const taskIDParsed = taskUrl[6] ?? ""; 76 | return taskIDParsed; 77 | } 78 | return ""; 79 | })(); 80 | 81 | /** 問題名 例: A - Cabbages */ 82 | export const taskTitle = ((): string => { 83 | if (pageType === "task") { 84 | // 問題ページでは、h2から問題名を取り出す 85 | return ( 86 | document 87 | .getElementsByClassName("h2")[0] 88 | ?.textContent?.trim() 89 | .replace(/\n.*/i, "") ?? "" 90 | ); 91 | } 92 | if (pageType === "submission") { 93 | // 個別の提出ページでは、問題リンクのテキストから問題名を取り出す 94 | // 提出情報の問題のテキストを取得する 95 | const taskCell = searchSubmissionInfo(["問題", "Task"]); 96 | if (!taskCell) return ""; 97 | const taskLink = taskCell.getElementsByTagName("a")[0]; 98 | if (!taskLink) return ""; 99 | return taskLink.textContent ?? ""; 100 | } 101 | return ""; 102 | })(); 103 | 104 | /** 提出ユーザー 例: machikane */ 105 | export const submissionsUser = ((): string => { 106 | if (pageType !== "submission") return ""; 107 | // 個別の提出ページのとき 108 | 109 | const userCell = searchSubmissionInfo(["ユーザ", "User"]); 110 | if (!userCell) return ""; 111 | 112 | return userCell?.textContent?.trim() ?? ""; 113 | })(); 114 | 115 | /** 提出結果 例: AC */ 116 | export const judgeStatus = ((): string => { 117 | if (pageType !== "submission") return ""; 118 | // 個別の提出ページのとき 119 | 120 | const statusCell = searchSubmissionInfo(["結果", "Status"]); 121 | if (!statusCell) return ""; 122 | 123 | return statusCell?.textContent?.trim() ?? ""; 124 | })(); 125 | 126 | /** 得点 例: 100 */ 127 | export const judgeScore = ((): number => { 128 | if (pageType !== "submission") return 0; 129 | // 個別の提出ページのとき 130 | 131 | const scoreCell = searchSubmissionInfo(["得点", "Score"]); 132 | if (!scoreCell) return 0; 133 | 134 | return parseInt(scoreCell?.textContent?.trim() ?? "0", 10); 135 | })(); 136 | -------------------------------------------------------------------------------- /src/utils/problemsIndex.ts: -------------------------------------------------------------------------------- 1 | // 次のコードを引用・編集 2 | // [AtCoderProblems/index\.ts at master · kenkoooo/AtCoderProblems](https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/utils/index.ts) 3 | // 5835f5dcacfa0cbdcc8ab1116939833d5ab71ed4 4 | import { Theme, ThemeLight } from "../style/theme"; 5 | 6 | export const clipDifficulty = (difficulty: number): number => 7 | Math.round( 8 | difficulty >= 400 ? difficulty : 400 / Math.exp(1.0 - difficulty / 400) 9 | ); 10 | 11 | export const RatingColors = [ 12 | "Black", 13 | "Grey", 14 | "Brown", 15 | "Green", 16 | "Cyan", 17 | "Blue", 18 | "Yellow", 19 | "Orange", 20 | "Red", 21 | ] as const; 22 | 23 | export type RatingColor = (typeof RatingColors)[number]; 24 | export const getRatingColor = (rating: number): RatingColor => { 25 | const index = Math.min(Math.floor(rating / 400), RatingColors.length - 2); 26 | return RatingColors[index + 1] ?? "Black"; 27 | }; 28 | export type RatingColorClassName = 29 | | "difficulty-black" 30 | | "difficulty-grey" 31 | | "difficulty-brown" 32 | | "difficulty-green" 33 | | "difficulty-cyan" 34 | | "difficulty-blue" 35 | | "difficulty-yellow" 36 | | "difficulty-orange" 37 | | "difficulty-red"; 38 | export const getRatingColorClass = (rating: number): RatingColorClassName => { 39 | const ratingColor = getRatingColor(rating); 40 | switch (ratingColor) { 41 | case "Black": 42 | return "difficulty-black"; 43 | case "Grey": 44 | return "difficulty-grey"; 45 | case "Brown": 46 | return "difficulty-brown"; 47 | case "Green": 48 | return "difficulty-green"; 49 | case "Cyan": 50 | return "difficulty-cyan"; 51 | case "Blue": 52 | return "difficulty-blue"; 53 | case "Yellow": 54 | return "difficulty-yellow"; 55 | case "Orange": 56 | return "difficulty-orange"; 57 | case "Red": 58 | return "difficulty-red"; 59 | default: 60 | return "difficulty-black"; 61 | } 62 | }; 63 | export const getRatingColorCode = ( 64 | ratingColor: RatingColor, 65 | theme: Theme = ThemeLight 66 | ): string => { 67 | switch (ratingColor) { 68 | case "Black": 69 | return theme.difficultyBlackColor; 70 | case "Grey": 71 | return theme.difficultyGreyColor; 72 | case "Brown": 73 | return theme.difficultyBrownColor; 74 | case "Green": 75 | return theme.difficultyGreenColor; 76 | case "Cyan": 77 | return theme.difficultyCyanColor; 78 | case "Blue": 79 | return theme.difficultyBlueColor; 80 | case "Yellow": 81 | return theme.difficultyYellowColor; 82 | case "Orange": 83 | return theme.difficultyOrangeColor; 84 | case "Red": 85 | return theme.difficultyRedColor; 86 | default: 87 | return theme.difficultyBlackColor; 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "noUncheckedIndexedAccess": true, 8 | "target": "es2021", 9 | "typeRoots": ["types", "node_modules/@types"], 10 | "resolveJsonModule": true 11 | }, 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /types/atcoder-embedded.d.ts: -------------------------------------------------------------------------------- 1 | declare let userScreenName: string; 2 | declare let contestScreenName: string; 3 | declare let startTime: import("moment").Moment; 4 | declare let endTime: import("moment").Moment; 5 | -------------------------------------------------------------------------------- /types/html.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /types/scss.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.scss" { 2 | const content: string; 3 | export default content; 4 | } 5 | --------------------------------------------------------------------------------