├── .eslintignore ├── .fixpackrc ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── package-lock.json ├── package.json ├── src ├── background │ ├── clar-watcher.ts │ ├── dispatcher.ts │ ├── notification.ts │ ├── oninstall.ts │ ├── submission-watcher.ts │ └── watching-list-manager.ts ├── content │ ├── add-tweet-button.ts │ ├── all.ts │ ├── betalib.ts │ ├── clar-notify.ts │ ├── dropdown-modify.ts │ ├── link-to-beta.ts │ ├── result-notify.ts │ └── submission-warning.ts ├── css │ ├── add-twitter-button.css │ ├── add-twitter-button.less │ ├── all.css │ ├── all.less │ ├── dropdown-modify.css │ └── dropdown-modify.less ├── image │ ├── beta.png │ ├── icon.png │ └── question.png ├── lib │ ├── jquery.cookie.js │ ├── jquery.min.js │ └── lock.ts ├── manifest.json └── options-page │ ├── options.css │ ├── options.html │ ├── options.less │ └── options.ts ├── tsconfig.json └── wercker.yml /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | src/lib 3 | -------------------------------------------------------------------------------- /.fixpackrc: -------------------------------------------------------------------------------- 1 | { 2 | "sortToTop": [ 3 | "name", 4 | "version", 5 | "description", 6 | "keywords", 7 | "homepage", 8 | "bugs", 9 | "license", 10 | "author", 11 | "files", 12 | "main", 13 | "bin", 14 | "man", 15 | "directories", 16 | "repository", 17 | "scripts", 18 | "config", 19 | "dependencies", 20 | "devDependencies", 21 | "peerDependencies", 22 | "bundledDependencies", 23 | "optionalDependencies", 24 | "engines", 25 | "os", 26 | "cpu", 27 | "private", 28 | "publishConfig" 29 | ], 30 | "required": [ 31 | "name", 32 | "version" 33 | ], 34 | "warn": [ 35 | "description", 36 | "keywords", 37 | "homepage", 38 | "bugs", 39 | "license", 40 | "author", 41 | "main", 42 | "repository" 43 | ], 44 | "sortedSubItems": [ 45 | "files", 46 | "directories", 47 | "dependencies", 48 | "devDependencies", 49 | "peerDependencies", 50 | "bundledDependencies", 51 | "optionalDependencies" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | .cache/ 4 | dist/ 5 | node_modules/ 6 | 7 | src/*.css 8 | 9 | Release.zip 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | "recommendations": [ 4 | "eamodio.gitlens", 5 | "bryan-chen.linter-xo-2", 6 | "shardulm94.trailing-spaces", 7 | "streetsidesoftware.code-spell-checker" 8 | ], 9 | "unwantedRecommendations": [] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.userWords": ["adplan"], 3 | "editor.formatOnSave": true, 4 | "editor.renderLineHighlight": "all", 5 | "editor.renderWhitespace": "boundary", 6 | "editor.rulers": [120], 7 | "editor.scrollBeyondLastLine": false, 8 | "editor.tabSize": 2, 9 | "files.insertFinalNewline": true, 10 | "files.trimFinalNewlines": true, 11 | "git.autofetch": true, 12 | "gitlens.advanced.messages": { 13 | "suppressShowKeyBindingsNotice": true 14 | }, 15 | "gitlens.gitExplorer.files.layout": "list", 16 | "html.suggest.ionic": false, 17 | "javascript.format.enable": false, 18 | "javascript.referencesCodeLens.enabled": true, 19 | "javascript.updateImportsOnFileMove.enabled": "always", 20 | "npm.enableScriptExplorer": true, 21 | "npm.packageManager": "npm", 22 | "trailing-spaces.highlightCurrentLine": false, 23 | "typescript.implementationsCodeLens.enabled": true, 24 | "typescript.referencesCodeLens.enabled": true, 25 | "workbench.statusBar.feedback.visible": false, 26 | "xo.format.enable": true, 27 | "xo.enable": true, 28 | "gitlens.views.repositories.files.layout": "list", 29 | "cSpell.words": [ 30 | "arial", 31 | "atcoder", 32 | "betalib", 33 | "brainfuck", 34 | "caml", 35 | "clar", 36 | "commonlib", 37 | "drafear", 38 | "dropdown", 39 | "env", 40 | "envs", 41 | "glyphicon", 42 | "helvetica", 43 | "lato", 44 | "multiline", 45 | "neue", 46 | "o", 47 | "oninstall", 48 | "unlambda" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Comfortable Atcoder 2 | This is a chrome extension which comforts your atcoder life. 3 | 4 | See [Features](#Features) for more detail. 5 | 6 | ## Installation 7 | 1. Download Release.zip from [Releases](https://github.com/drafear/comfortable-atcoder/releases) and extract it 8 | 9 | 2. Open [chrome://extensions/](chrome://extensions/) 10 | 11 | 3. Drag & drop the directory to the page to load this chrome extension 12 | 13 | 14 | ## Features 15 | - Notify judge result of codes you submit 16 | - Notify new clarifications on the contest page you open 17 | - ~~Sync favorite users~~ 18 | - Add a link tab to beta page on non-beta pages 19 | - Dropdown list of problems 20 | - Warn if you select specific languages for submission such as `text`, `bash` and so on (configurable) 21 | - Disable/Enable them 22 | 23 | ## Developing 24 | To develop this chrome extention, clone this repository first. 25 | 26 | You can install recommended modules for [Visual Studio Code](https://code.visualstudio.com/) from `@recommended`. 27 | 28 | ### Build for developing 29 | 1. Run the following command to watch the `src` directory: 30 | ```bash 31 | npm run watch 32 | ``` 33 | 2. Load `src` as a chrome extension on [](chrome://extensions/) 34 | 3. After you edit some source file and save it, reload [](chrome://extensions/) to apply the changes 35 | 36 | ### Build for release 37 | 1. Run the following command to build the `dist` directory: 38 | ```bash 39 | npm run build 40 | ``` 41 | 2. Load `dist` as a chrome extension on [](chrome://extensions/) 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "comfortable-atcoder", 3 | "description": "Comfort your atcoder life. For more detail, visit https://github.com/drafear/comfortable-atcoder", 4 | "scripts": { 5 | "build": "run-s build:*", 6 | "build:init": "rm -rf dist", 7 | "build:cp": "cp -r src dist && rm -rf dist/**/*.less dist/**/*.ts dist/**/*.map", 8 | "build:css": "less-watch-compiler --run-once src dist", 9 | "build:ts": "parcel build --target node src/**/*.ts --no-source-maps", 10 | "start": "npm run watch", 11 | "watch": "run-p watch:*", 12 | "watch:less": "less-watch-compiler src dist", 13 | "watch:build": "parcel --target node src/**/*.ts --no-source-maps", 14 | "watch:test": "jest --watch", 15 | "package": "npm run build && zip -rq Release.zip dist" 16 | }, 17 | "dependencies": {}, 18 | "devDependencies": { 19 | "@types/async-lock": "1.1.0", 20 | "@types/chrome": "0.0.75", 21 | "@types/jest": "23.3.10", 22 | "@types/jquery": "3.3.23", 23 | "ajv": "6.6.1", 24 | "eslint": "5.9.0", 25 | "eslint-config-xo-typescript": "0.3.0", 26 | "eslint-plugin-typescript": "0.14.0", 27 | "event-stream": "3.3.4", 28 | "jest": "23.6.0", 29 | "less-watch-compiler": "1.11.3", 30 | "npm-run-all": "4.1.3", 31 | "parcel": "1.10.3", 32 | "semantic-release": "15.9.15", 33 | "ts-jest": "23.10.5", 34 | "typescript": "3.2.1", 35 | "typescript-eslint-parser": "21.0.2", 36 | "xo": "0.23.0" 37 | }, 38 | "engines": { 39 | "node": "10", 40 | "npm": "6.3" 41 | }, 42 | "private": true, 43 | "default_locale": "en", 44 | "eslintIgnore": [ 45 | "dist", 46 | "src/lib" 47 | ], 48 | "jest": { 49 | "testMatch": [ 50 | "**/*.test.(js|ts)" 51 | ], 52 | "transform": { 53 | ".\\.ts$": "ts-jest" 54 | }, 55 | "moduleFileExtensions": [ 56 | "ts", 57 | "js", 58 | "json" 59 | ] 60 | }, 61 | "prettier": { 62 | "bracketSpacing": true, 63 | "printWidth": 120, 64 | "singleQuote": true, 65 | "trailingComma": "all" 66 | }, 67 | "release": { 68 | "verifyConditions": [ 69 | "@semantic-release/github" 70 | ], 71 | "publish": [ 72 | { 73 | "path": "@semantic-release/github", 74 | "assets": [ 75 | { 76 | "path": "Release.zip" 77 | } 78 | ] 79 | } 80 | ], 81 | "success": [ 82 | "@semantic-release/github" 83 | ], 84 | "fail": [ 85 | "@semantic-release/github" 86 | ] 87 | }, 88 | "xo": { 89 | "extends": "xo-typescript", 90 | "prettier": true, 91 | "envs": [ 92 | "es6" 93 | ], 94 | "space": true, 95 | "strict": true, 96 | "rules": { 97 | "quotes": [ 98 | "error", 99 | "single", 100 | { 101 | "avoidEscape": true 102 | } 103 | ], 104 | "typescript/explicit-function-return-type": 0, 105 | "no-useless-constructor": 0, 106 | "no-alert": 0, 107 | "no-negated-condition": 0, 108 | "capitalized-comments": 0, 109 | "no-constant-condition": [ 110 | "error", 111 | { 112 | "checkLoops": false 113 | } 114 | ], 115 | "default-case": 0, 116 | "no-irregular-whitespace": 0, 117 | "no-await-in-loop": 0, 118 | "no-undef": 0, 119 | "comma-dangle": [ 120 | "error", 121 | "always-multiline" 122 | ] 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/background/clar-watcher.ts: -------------------------------------------------------------------------------- 1 | import { createNotification } from './notification'; 2 | import * as Betalib from '../content/betalib'; 3 | import { WatchingListManager } from './watching-list-manager'; 4 | import { Lock } from '../lib/lock'; 5 | 6 | const watchingClarManager = new WatchingListManager('clarification', 24 * 60 * 60 * 1000, 0); 7 | 8 | async function getClarCount(contestId: string): Promise { 9 | const response = await fetch(`https://atcoder.jp/contests/${contestId}/clarifications/count`); 10 | const curClarCount = Number(await response.text()); 11 | return curClarCount; 12 | } 13 | 14 | export async function checkClarification(contest: Betalib.Contest, notifyLock: Lock) { 15 | const clarCount = await getClarCount(contest.id); 16 | const prevClarCount = await watchingClarManager.get(contest.id); 17 | if (clarCount > prevClarCount) { 18 | watchingClarManager.set(contest.id, clarCount); 19 | createNotification({ 20 | data: { 21 | type: 'basic', 22 | iconUrl: chrome.extension.getURL('image/question.png'), 23 | title: 'Atcoder', 24 | message: 'New Clarification', 25 | }, 26 | href: `https://atcoder.jp/contests/${contest.id}/clarifications`, 27 | }, notifyLock); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/background/dispatcher.ts: -------------------------------------------------------------------------------- 1 | import { createNotification } from './notification' 2 | import { watchSubmissionRegister } from './submission-watcher'; 3 | import { checkClarification } from './clar-watcher'; 4 | import { Lock } from '../lib/lock'; 5 | 6 | const notifyLock = new Lock(); 7 | 8 | chrome.runtime.onMessage.addListener(({ type, data }) => { 9 | switch (type) { 10 | case 'create-notification': 11 | createNotification(data, notifyLock); 12 | break; 13 | case 'watch-submission-register': 14 | watchSubmissionRegister(data, notifyLock); 15 | break; 16 | case 'check-clarification': 17 | checkClarification(data, notifyLock); 18 | break; 19 | default: 20 | console.error(`unknown message: ${type}`); 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /src/background/notification.ts: -------------------------------------------------------------------------------- 1 | import { Lock } from '../lib/lock'; 2 | import { sleep } from '../content/all'; 3 | 4 | export interface CreateNotificationParam { 5 | data: chrome.notifications.NotificationOptions; 6 | href: string; 7 | } 8 | 9 | export async function createNotification({ data, href }: CreateNotificationParam, lock: Lock) { 10 | let notificationId: string; 11 | await lock.acquire(async () => { 12 | data.requireInteraction = true; 13 | const clickHandler = (id: string) => { 14 | if (id === notificationId) { 15 | chrome.tabs.create({ url: href }); 16 | } 17 | }; 18 | chrome.notifications.onClicked.addListener(clickHandler); 19 | // create notification and get notification id 20 | await new Promise(resolve => { 21 | chrome.notifications.create(data, async id => { 22 | notificationId = id; 23 | resolve(); 24 | }); 25 | }); 26 | await sleep(8000); 27 | chrome.notifications.clear(notificationId); 28 | chrome.notifications.onClicked.removeListener(clickHandler); 29 | await sleep(1000); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/background/oninstall.ts: -------------------------------------------------------------------------------- 1 | chrome.runtime.onInstalled.addListener(details => { 2 | if (details.reason === 'install') { 3 | window.open(chrome.extension.getURL('options-page/options.html')); 4 | } 5 | }); 6 | -------------------------------------------------------------------------------- /src/background/submission-watcher.ts: -------------------------------------------------------------------------------- 1 | import { Lock } from '../lib/lock'; 2 | import { sleep } from '../content/all'; 3 | import * as Betalib from '../content/betalib'; 4 | import { createNotification } from './notification'; 5 | import { WatchingSetManager } from './watching-list-manager'; 6 | 7 | interface MessageResultError { 8 | error: string; 9 | } 10 | 11 | interface JudgeResultImageStyle { 12 | foreColor?: string; 13 | backColor?: string; 14 | } 15 | 16 | const judgeResultImageStyle: { [key: string]: JudgeResultImageStyle } = { 17 | AC: { backColor: '#5cb85c' }, 18 | WA: { backColor: 'hsl(0, 84%, 62%)' }, 19 | }; 20 | 21 | function makeJudgeStatusImageUrl(judgeResult: string): string { 22 | let foreColor = 'white'; 23 | let backColor = '#f0ad4e'; 24 | if (judgeResult in judgeResultImageStyle) { 25 | const newStyle = judgeResultImageStyle[judgeResult]; 26 | if (newStyle.foreColor !== undefined) { 27 | foreColor = newStyle.foreColor; 28 | } 29 | if (newStyle.backColor !== undefined) { 30 | backColor = newStyle.backColor; 31 | } 32 | } 33 | const canvas = document.createElement('canvas'); 34 | canvas.width = 192; 35 | canvas.height = 192; 36 | const ctx = canvas.getContext('2d'); 37 | if (ctx === null) throw new Error("canvas.getContext('2d') was failed"); 38 | ctx.fillStyle = backColor; 39 | ctx.fillRect(0, 0, canvas.width, canvas.height); 40 | ctx.font = "80px 'Lato','Helvetica Neue',arial,sans-serif"; 41 | ctx.fillStyle = foreColor; 42 | ctx.textAlign = 'center'; 43 | ctx.textBaseline = 'middle'; 44 | ctx.fillText(judgeResult, canvas.width / 2, canvas.height / 2); 45 | return canvas.toDataURL('image/png'); 46 | } 47 | 48 | const watchingSubmissionManager = new WatchingSetManager('submission'); 49 | 50 | class SubmissionWatcher { 51 | private maxSleepMilliseconds: number; 52 | 53 | constructor(readonly submission: Betalib.Submission, private notifyLock: Lock) { 54 | this.maxSleepMilliseconds = 5 * 1000; 55 | } 56 | 57 | async start(timeout = 30 * 60 * 1000) { 58 | console.log('SubmissionWatcher: start:', this.submission); 59 | const startTime = Date.now(); 60 | let prevTime = startTime; 61 | let prevStatus = this.submission.judgeStatus; 62 | await sleep(100); // Rejudge用 63 | while (true) { 64 | const submission = await this.getCurrentSubmission(); 65 | console.log('SubmissionWatcher: in progress:', this.submission, submission); 66 | if (!submission.judgeStatus.isWaiting) { 67 | let message = ''; 68 | // ジャッジ中か 69 | if (submission.judgeStatus.now !== undefined) { 70 | message += 'Judging...'; 71 | } else { 72 | message += `Score: ${submission.score} points`; 73 | } 74 | // 結果が取得できたなら表示 75 | if (submission.execTime) { 76 | message += `\n${submission.execTime}`; 77 | } 78 | if (submission.memoryUsage) { 79 | message += `\n${submission.memoryUsage}`; 80 | } 81 | console.log('SubmissionWatcher: notification:', this.submission, submission); 82 | createNotification({ 83 | data: { 84 | type: 'basic', 85 | iconUrl: makeJudgeStatusImageUrl(submission.judgeStatus.text), 86 | title: submission.probTitle, 87 | message, 88 | }, 89 | href: submission.detailAbsoluteUrl, 90 | }, this.notifyLock); 91 | break; 92 | } 93 | const curTime = Date.now(); 94 | if (curTime - startTime >= timeout) { 95 | break; 96 | } 97 | const dt = curTime - prevTime; 98 | let sleepMilliseconds = this.maxSleepMilliseconds; 99 | if (prevStatus.now !== undefined) { 100 | const diff = (submission.judgeStatus.now as number) - prevStatus.now; 101 | const estimated = dt === 0 ? 0 : Math.floor(((submission.judgeStatus.rest as number) * dt) / (diff + 1)); 102 | sleepMilliseconds = Math.min(sleepMilliseconds, Math.max(estimated, 1 * 1000)); 103 | // ジャッジが進まないなら頻度を下げる 104 | if (diff === 0) { 105 | this.maxSleepMilliseconds = Math.floor(this.maxSleepMilliseconds * 1.2); 106 | } 107 | } 108 | prevTime = curTime; 109 | prevStatus = submission.judgeStatus; 110 | await sleep(sleepMilliseconds); 111 | } 112 | } 113 | 114 | async getCurrentSubmission() { 115 | // submission画面を開いているものがあればそこから取得 116 | const tabs: chrome.tabs.Tab[] = await new Promise(resolve => { 117 | chrome.tabs.query({ url: `*://atcoder.jp/contests/${this.submission.contest.id}/submissions/me` }, resolve); 118 | }); 119 | for (const tab of tabs) { 120 | if (tab.id === undefined) { 121 | continue; 122 | } 123 | const result: Betalib.Submission | MessageResultError | null = await Promise.race([ 124 | new Promise(resolve => { 125 | const message = { type: "get-submission", id: this.submission.id }; 126 | console.log(`send message to tab ${tab.id!}:`, this.submission); 127 | chrome.tabs.sendMessage(tab.id!, message, resolve); 128 | }), 129 | (async () => { 130 | await sleep(1 * 1000); 131 | return null; 132 | })(), 133 | ]); 134 | if (result) { 135 | if ('error' in result) { 136 | console.error(result.error); 137 | } 138 | else { 139 | console.log('submission was found in tabs:', result); 140 | return result; 141 | } 142 | } 143 | } 144 | // 取得できなかった場合、detail画面をfetchする 145 | const response = await fetch(this.submission.detailAbsoluteUrl, { cache: 'no-cache' }); 146 | const html = await response.text(); 147 | const root = new DOMParser().parseFromString(html, 'text/html'); 148 | const result = Betalib.parseSubmissionFromDetailPage(root, this.submission); 149 | console.log('submission was fetched:', result); 150 | return result; 151 | } 152 | } 153 | 154 | const lock = new Lock(); 155 | 156 | export async function watchSubmissionRegister(submission: Betalib.Submission, notifyLock: Lock): Promise { 157 | let has = false; 158 | await lock.acquire(async () => { 159 | if (await watchingSubmissionManager.has(submission.id)) { 160 | has = true; 161 | return; 162 | } 163 | await watchingSubmissionManager.add(submission.id); 164 | }); 165 | if (has) return; 166 | try { 167 | await new SubmissionWatcher(submission, notifyLock).start(); 168 | } catch (error) { 169 | throw error; 170 | } finally { 171 | await sleep(1000 * 10); 172 | await watchingSubmissionManager.delete(submission.id); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/background/watching-list-manager.ts: -------------------------------------------------------------------------------- 1 | interface WatchingData { 2 | data: DataType, 3 | updatedAt: number, 4 | } 5 | 6 | export class WatchingListManager { 7 | constructor(readonly id: string, private readonly timeToRemove: number, readonly defaultData: DataType) { } 8 | 9 | private get storageKey(): string { 10 | return `WLM-${this.id}`; 11 | } 12 | 13 | async clear() { 14 | await new Promise(resolve => { 15 | chrome.storage.local.set({ [this.storageKey]: {} }, resolve); 16 | }); 17 | } 18 | 19 | private async getCurrentWatchingList(now = Date.now()): Promise<{ [key: string]: WatchingData }> { 20 | const watchingList: { [key: string]: WatchingData } = 21 | await new Promise(resolve => { 22 | chrome.storage.local.get({ [this.storageKey]: {} }, items => { 23 | resolve(items[this.storageKey]); 24 | }); 25 | }); 26 | // remove outdated data 27 | for (const [key, val] of Object.entries(watchingList)) { 28 | if (val.updatedAt > now + this.timeToRemove) { 29 | delete watchingList[key]; 30 | } 31 | } 32 | console.log('WatchingListManager: getCurrentWatchingList:', watchingList); 33 | return watchingList; 34 | } 35 | 36 | getKey(watchingId: string) { 37 | return `#id:${watchingId}`; 38 | } 39 | 40 | async set(watchingId: string, data: DataType) { 41 | const now = Date.now(); 42 | const watchingList = await this.getCurrentWatchingList(now); 43 | // update 44 | watchingList[this.getKey(watchingId)] = { 45 | updatedAt: now, 46 | data: data, 47 | }; 48 | // save 49 | console.log("save", watchingList); 50 | await new Promise(resolve => { 51 | chrome.storage.local.set({ [this.storageKey]: watchingList }, resolve); 52 | }); 53 | } 54 | 55 | async isWatching(watchingId: string): Promise { 56 | const watchingList = await this.getCurrentWatchingList(); 57 | return this.getKey(watchingId) in watchingList; 58 | } 59 | 60 | async get(watchingId: string): Promise { 61 | const watchingList = await this.getCurrentWatchingList(); 62 | if (this.getKey(watchingId) in watchingList) { 63 | return watchingList[this.getKey(watchingId)].data; 64 | } 65 | return this.defaultData; 66 | } 67 | 68 | async remove(watchingId: string) { 69 | const watchingList = await this.getCurrentWatchingList(); 70 | // update 71 | delete watchingList[this.getKey(watchingId)]; 72 | // save 73 | await new Promise(resolve => { 74 | chrome.storage.local.set({ [this.storageKey]: watchingList }, resolve); 75 | }); 76 | } 77 | } 78 | 79 | export class WatchingSetManager { 80 | private manager: WatchingListManager; 81 | 82 | constructor(id: string, timeToRemove: number = 24 * 60 * 60 * 1000) { 83 | this.manager = new WatchingListManager(id, timeToRemove, null); 84 | } 85 | 86 | async has(watchingId: string): Promise { 87 | return await this.manager.isWatching(watchingId); 88 | } 89 | async add(watchingId: string) { 90 | this.manager.set(watchingId, null); 91 | } 92 | async delete(watchingId: string) { 93 | await this.manager.remove(watchingId); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/content/add-tweet-button.ts: -------------------------------------------------------------------------------- 1 | import * as Commonlib from './all'; 2 | import * as Betalib from './betalib'; 3 | 4 | Commonlib.runIfEnableAndLoad('add-tweet-button', async () => { 5 | const isMyPage = $('#user-nav-tabs .glyphicon-cog').length >= 1; 6 | if (!isMyPage) { 7 | return; 8 | } 9 | 10 | const isDetailPage = /^\/users\/[^/]+\/history\/?$/.test(location.pathname); 11 | const userId = (location.pathname.match(/^\/users\/([^/]+)/) as string[])[1]; 12 | 13 | async function getTable(): Promise> { 14 | // if (isDetailPage) { 15 | // return $('#history'); 16 | // } 17 | // else { 18 | const html = await (await fetch(`/users/${userId}/history`)).text(); 19 | return $(html).find('#history'); 20 | // } 21 | } 22 | 23 | function getLatestContestResult(contestResults: Betalib.ContestResult[]): Betalib.ContestResult | null { 24 | if (contestResults.length === 0) { 25 | return null; 26 | } 27 | let res = contestResults[0]; 28 | for (const result of contestResults) { 29 | if (result.date > res.date) { 30 | res = result; 31 | } 32 | } 33 | return res; 34 | } 35 | 36 | function makeTweetText(contestResult: Betalib.ContestResult, isHighest = false): string { 37 | const r = contestResult; 38 | if (r instanceof Betalib.RatedContestResult) { 39 | const highestStr = isHighest ? ', Highest!!' : ''; 40 | return `I took ${r.getRankStr()} place in ${r.contestName}\n\nRating: ${r.newRating - r.diff} -> ${ 41 | r.newRating 42 | } (${r.getDiffStr()}${highestStr})\nPerformance: ${r.performance}\n#${r.contestId}`; 43 | } 44 | 45 | return `I took ${r.getRankStr()} place in ${r.contestName}\n#${r.contestId}`; 46 | } 47 | 48 | function isHighest(targetContestResult: Betalib.ContestResult, contestResults: Betalib.ContestResult[]) { 49 | if (!(targetContestResult instanceof Betalib.RatedContestResult)) { 50 | return false; 51 | } 52 | for (const result of contestResults) { 53 | if (result.contestId === targetContestResult.contestId) { 54 | continue; 55 | } 56 | if (!(result instanceof Betalib.RatedContestResult)) { 57 | continue; 58 | } 59 | if (result.newRating >= targetContestResult.newRating) { 60 | return false; 61 | } 62 | } 63 | return true; 64 | } 65 | 66 | const $table = await getTable(); 67 | const contestResults = Betalib.getContestResultsFromTable($table); 68 | const latestContestResult = getLatestContestResult(contestResults); 69 | // 一度も参加したことがない 70 | if (latestContestResult === null) { 71 | return; 72 | } 73 | const tweetContent = makeTweetText(latestContestResult, isHighest(latestContestResult, contestResults)); 74 | const text = 75 | navigator.language === 'ja' ? '最新のコンテスト結果をツイート' : 'Tweet the result of the latest contest'; 76 | const $tweetButton = $('') 77 | .addClass('tweet') 78 | .text(text) 79 | .prop('href', `https://twitter.com/share?url=''&text=${encodeURIComponent(tweetContent)}`) 80 | .prop('target', '_blank'); 81 | if (isDetailPage) { 82 | $('#history_wrapper > div.row:first-child > .col-sm-6:first-child') 83 | .eq(0) 84 | .prepend($tweetButton); 85 | } 86 | }); 87 | -------------------------------------------------------------------------------- /src/content/all.ts: -------------------------------------------------------------------------------- 1 | export function isEnable(storageKey: string): Promise { 2 | return new Promise(resolve => { 3 | chrome.storage.sync.get([storageKey], result => { 4 | if (storageKey in result) { 5 | resolve(Boolean(result[storageKey])); 6 | } else { 7 | resolve(false); 8 | } 9 | }); 10 | }); 11 | } 12 | 13 | export async function domLoad(): Promise { 14 | await new Promise(resolve => { 15 | $(() => { 16 | resolve(); 17 | }); 18 | }); 19 | } 20 | 21 | export async function runIfEnableAndLoad(storageKey: string, fn: Function): Promise { 22 | const [enable] = await Promise.all([isEnable(storageKey), domLoad()]); 23 | if (enable) { 24 | fn(); 25 | } 26 | } 27 | 28 | export async function sleep(ms: number): Promise { 29 | await new Promise(resolve => setTimeout(resolve, ms)); 30 | } 31 | 32 | export function createNotification(params: any): void { 33 | chrome.runtime.sendMessage({ type: 'create-notification', data: params }); 34 | } 35 | -------------------------------------------------------------------------------- /src/content/betalib.ts: -------------------------------------------------------------------------------- 1 | const betaHost = 'atcoder.jp'; 2 | 3 | export class Contest { 4 | public readonly url: string; 5 | 6 | constructor(public readonly id: string) { 7 | this.url = `/contests/${this.id}`; 8 | } 9 | } 10 | 11 | export interface ProblemOption { 12 | contest: Contest; 13 | id: string; 14 | title: string; 15 | alphabet: string; 16 | } 17 | 18 | export class Problem { 19 | public readonly contest: Contest; 20 | 21 | public readonly id: string; 22 | 23 | public readonly title: string; 24 | 25 | public readonly alphabet: string; 26 | 27 | public readonly url: string; 28 | 29 | constructor({ contest, id, title, alphabet }: ProblemOption) { 30 | this.contest = contest; 31 | this.id = id; 32 | this.title = title; 33 | this.alphabet = alphabet; 34 | this.url = `${this.contest.url}/tasks/${this.id}`; 35 | } 36 | } 37 | 38 | export interface SubmissionOption { 39 | contest: Contest; 40 | id: string; 41 | probTitle: string; 42 | score: string; 43 | judgeStatus: JudgeStatus; 44 | execTime?: string; 45 | memoryUsage?: string; 46 | } 47 | 48 | export class Submission { 49 | public readonly contest: Contest; 50 | 51 | public readonly id: string; 52 | 53 | public readonly score: string; 54 | 55 | public readonly judgeStatus: JudgeStatus; 56 | 57 | public readonly execTime: string | undefined; 58 | 59 | public readonly memoryUsage: string | undefined; 60 | 61 | public readonly probTitle: string; 62 | 63 | public readonly detailUrl: string; 64 | 65 | public readonly detailAbsoluteUrl: string; 66 | 67 | constructor({ contest, id, probTitle, score, judgeStatus, execTime, memoryUsage }: SubmissionOption) { 68 | this.contest = contest; 69 | this.id = id; 70 | this.score = score; 71 | this.judgeStatus = judgeStatus; 72 | this.execTime = execTime; 73 | this.memoryUsage = memoryUsage; 74 | this.probTitle = probTitle; 75 | this.detailUrl = `${contest.url}/submissions/${id}`; 76 | this.detailAbsoluteUrl = `https://${betaHost}${this.detailUrl}`; 77 | } 78 | } 79 | 80 | export function getIndexes( 81 | $items: JQuery, 82 | patternObj: { [key: string]: string[] }, 83 | ): { [key: string]: number } { 84 | const res: { [key: string]: number } = {}; 85 | for (let i = 0; i < $items.length; ++i) { 86 | const content = $items.eq(i).text(); 87 | for (const [key, patterns] of Object.entries(patternObj)) { 88 | for (const pattern of patterns) { 89 | if (content.search(pattern) >= 0) { 90 | res[key] = i; 91 | break; 92 | } 93 | } 94 | } 95 | } 96 | return res; 97 | } 98 | 99 | export interface JudgeStatusOption { 100 | text: string; 101 | now?: number; 102 | total?: number; 103 | } 104 | 105 | export class JudgeStatus { 106 | public readonly text: string; 107 | 108 | public readonly now: number | undefined; 109 | 110 | public readonly total: number | undefined; 111 | 112 | public readonly isWaiting: boolean; 113 | 114 | public readonly rest: number | undefined; 115 | 116 | constructor({ text, now, total }: JudgeStatusOption) { 117 | this.text = text; 118 | this.now = now; 119 | this.total = total; 120 | this.isWaiting = text === 'WJ' || text === 'WR'; 121 | this.rest = total !== undefined && now !== undefined ? total - now : undefined; 122 | } 123 | } 124 | 125 | export abstract class ContestResult { 126 | constructor( 127 | public readonly date: Date, 128 | public readonly contestName: string, 129 | public readonly contestId: string, 130 | public readonly rank: number, 131 | public readonly diff: number, 132 | ) { } 133 | 134 | abstract isRated(): boolean; 135 | 136 | getRankStr(): string { 137 | switch (this.rank % 10) { 138 | case 1: 139 | return `${this.rank}st`; 140 | case 2: 141 | return `${this.rank}nd`; 142 | case 3: 143 | return `${this.rank}rd`; 144 | default: 145 | return `${this.rank}th`; 146 | } 147 | } 148 | 149 | getDiffStr(): string { 150 | if (this.diff > 0) { 151 | return `+${this.diff}`; 152 | } 153 | if (this.diff < 0) { 154 | return this.diff.toString(); 155 | } 156 | return '±0'; 157 | } 158 | } 159 | export class UnRatedContestResult extends ContestResult { 160 | isRated() { 161 | return false; 162 | } 163 | } 164 | export class RatedContestResult extends ContestResult { 165 | constructor( 166 | date: Date, 167 | contestName: string, 168 | contestId: string, 169 | rank: number, 170 | diff: number, 171 | public readonly performance: number, 172 | public readonly newRating: number, 173 | ) { 174 | super(date, contestName, contestId, rank, diff); 175 | } 176 | 177 | isRated() { 178 | return true; 179 | } 180 | } 181 | 182 | export function parseJudgeStatus(text: string): JudgeStatus { 183 | const reg = /[ \s]/g; 184 | // WJ 185 | if (text.search('/') >= 0) { 186 | const [progress, status] = text.search(' ') >= 0 ? text.split(' ') : [text, '']; 187 | const [now, total] = progress.split('/'); 188 | return new JudgeStatus({ 189 | text: status.replace(reg, '') || 'WJ', 190 | now: Number(now.replace(reg, '')), 191 | total: Number(total.replace(reg, '')), 192 | }); 193 | } 194 | return new JudgeStatus({ text: text.replace(reg, '') }); 195 | } 196 | 197 | export function parseSubmissionFromDetailPage(htmlRoot: Document, submission: Submission) { 198 | const $table = $(htmlRoot.querySelector('table') as HTMLTableElement); 199 | const $ths = $table.find('th'); 200 | const indexes = getIndexes($ths, { 201 | score: ['点', 'Score'], 202 | status: ['結果', 'Status'], 203 | time: ['実行時間', 'Exec Time'], 204 | memory: ['メモリ', 'Memory'], 205 | }); 206 | if (!('status' in indexes)) { 207 | throw new Error("getCurrentSubmission: Can't get status"); 208 | } 209 | const $tds = $table.find('td'); 210 | const { id: submissionId, contest, probTitle } = submission; 211 | const score = $tds.eq(indexes.score).text(); 212 | const judgeStatus = parseJudgeStatus( 213 | $tds 214 | .eq(indexes.status) 215 | .children('span') 216 | .text(), 217 | ); 218 | const execTime = 'time' in indexes ? $tds.eq(indexes.time).text() : undefined; 219 | const memoryUsage = 'memory' in indexes ? $tds.eq(indexes.memory).text() : undefined; 220 | return new Submission({ contest, id: submissionId, probTitle, score, judgeStatus, execTime, memoryUsage }); 221 | } 222 | 223 | export function getCurrentContest(): Contest { 224 | const contestId = (location.pathname.match(/^\/contests\/([^/]+)/) as string[])[1]; 225 | return new Contest(contestId); 226 | } 227 | 228 | export function watchSubmission(submission: Submission): void { 229 | chrome.runtime.sendMessage({ type: 'watch-submission-register', data: submission }); 230 | } 231 | 232 | export async function getMySubmissions(): Promise { 233 | const contest = getCurrentContest(); 234 | let $html: JQuery; 235 | // 既に自分の提出ページを開いているならfetchする必要なし 236 | if (location.pathname.match(new RegExp(`\\/contests\\/${contest.id}\\/submissions\\/me\\/?$`))) { 237 | $html = $('html'); 238 | } else { 239 | const response = await fetch(`${contest.url}/submissions/me?lang=ja`); 240 | const html = await response.text(); 241 | $html = $(html); 242 | } 243 | const $th = $('thead > tr > th', $html); 244 | const indexes = getIndexes($th, { 245 | prob: ['問題', 'Task'], 246 | score: ['点', 'Score'], 247 | status: ['結果', 'Status'], 248 | time: ['実行時間', 'Exec Time'], 249 | memory: ['メモリ', 'Memory'], 250 | }); 251 | if ($th.length === 0) { 252 | return []; 253 | } 254 | if (!('status' in indexes)) { 255 | throw new Error("Betalib: getMySubmissions: Can't get status"); 256 | } 257 | if (!('score' in indexes)) { 258 | throw new Error("Betalib: getMySubmissions: Can't get score"); 259 | } 260 | const res: Submission[] = []; 261 | $('tbody > tr', $html).each((idx, elem) => { 262 | const $tds = $(elem).children('td'); 263 | const submissionId = $tds.eq(indexes.score)[0].dataset.id as string; 264 | const probTitle = $tds 265 | .eq(indexes.prob) 266 | .children('a') 267 | .text(); 268 | const score = $tds.eq(indexes.score).text(); 269 | const judgeStatus = parseJudgeStatus( 270 | $tds 271 | .eq(indexes.status) 272 | .children('span') 273 | .text(), 274 | ); 275 | let execTime: string | undefined = $tds.eq(indexes.time).text(); 276 | if (!/s$/.test(execTime)) execTime = undefined; 277 | let memoryUsage: string | undefined = $tds.eq(indexes.memory).text(); 278 | if (!/B$/.test(memoryUsage)) memoryUsage = undefined; 279 | res[idx] = new Submission({ contest, id: submissionId, probTitle, score, judgeStatus, execTime, memoryUsage }); 280 | }); 281 | return res; 282 | } 283 | 284 | export async function getProblems(): Promise { 285 | const contest = getCurrentContest(); 286 | let $html; 287 | // 既に問題ページを開いているならfetchする必要なし 288 | if (location.pathname.match(new RegExp(`\\/contests\\/${contest.id}\\/tasks\\/?$`))) { 289 | $html = $('html'); 290 | } else { 291 | const response = await fetch(`${contest.url}/tasks?lang=ja`); 292 | const html = await response.text(); 293 | $html = $(html); 294 | } 295 | const $table = $('table', $html).eq(0); 296 | const $th = $('thead > tr > th', $table); 297 | const { prob: probColIdx } = getIndexes($th, { prob: ['Task Name', '問題'] }); 298 | if (probColIdx === undefined) { 299 | throw new Error("Betalib: getProblems: Can't get probColIdx"); 300 | } 301 | const res: Problem[] = []; 302 | const reg = new RegExp(`${contest.url.replace(/\//g, '\\/')}\\/tasks\\/([^/]+)`); 303 | $(`tbody > tr > td:nth-child(${probColIdx + 1})`, $table).each((idx, elem) => { 304 | const $a = $(elem).children('a'); 305 | const problemId = (($a.attr('href') as string).match(reg) as string[])[1]; 306 | const title = $a.text(); 307 | let alphabet = $a 308 | .closest('tr') 309 | .children('td') 310 | .eq(0) 311 | .text(); 312 | if (!alphabet.match(/^[A-Z]+$/)) { 313 | alphabet = 'X'; 314 | } 315 | res[idx] = new Problem({ contest, id: problemId, title, alphabet }); 316 | }); 317 | return res; 318 | } 319 | 320 | export function getContestResultsFromTable($table: JQuery): ContestResult[] { 321 | const res: ContestResult[] = []; 322 | const $th = $('thead > tr > th', $table); 323 | const indexes = getIndexes($th, { 324 | date: ['Date', '日付'], 325 | contest: ['Contest', 'コンテスト'], 326 | rank: ['Rank', '順位'], 327 | performance: ['Performance', 'パフォーマンス'], 328 | newRating: ['NewRating', '新Rating'], 329 | diff: ['Diff', '差分'], 330 | }); 331 | $('tbody > tr', $table).each((idx, tr) => { 332 | const $tds = $(tr).children('td'); 333 | const date = new Date($tds.eq(indexes.date).text()); 334 | const $contest = $tds 335 | .eq(indexes.contest) 336 | .children('a') 337 | .eq(0); 338 | const contestName = $contest.text(); 339 | const contestId = (($contest.prop('href') as string).match(/\/contests\/([^/]+)\/?$/) as string[])[1]; 340 | const rank = Number($tds.eq(indexes.rank).text()); 341 | const performanceStr = $tds.eq(indexes.performance).text(); 342 | const newRatingStr = $tds.eq(indexes.newRating).text(); 343 | const diff = Number( 344 | $tds 345 | .eq(indexes.diff) 346 | .text() 347 | .replace(/\D/g, ''), 348 | ); 349 | const isRated = performanceStr !== '-'; 350 | res[idx] = isRated 351 | ? new RatedContestResult(date, contestName, contestId, rank, diff, Number(performanceStr), Number(newRatingStr)) 352 | : new UnRatedContestResult(date, contestName, contestId, rank, diff); 353 | }); 354 | return res; 355 | } 356 | -------------------------------------------------------------------------------- /src/content/clar-notify.ts: -------------------------------------------------------------------------------- 1 | import * as Commonlib from './all'; 2 | import * as Betalib from './betalib'; 3 | 4 | function getNotifyCount(): number { 5 | const text = $('#clar-badge').text(); 6 | const res = /\d+/.test(text) ? Number(text) : 0; 7 | return res; 8 | } 9 | 10 | Commonlib.runIfEnableAndLoad('notify-clarification', async () => { 11 | const contest = Betalib.getCurrentContest(); 12 | 13 | function updateBackground() { 14 | chrome.runtime.sendMessage({ type: 'check-clarification', data: contest }); 15 | } 16 | 17 | let prev = getNotifyCount(); 18 | while (true) { 19 | const cur = getNotifyCount(); 20 | if (cur > prev) { 21 | updateBackground(); 22 | prev = cur; 23 | } 24 | await Commonlib.sleep(1000); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /src/content/dropdown-modify.ts: -------------------------------------------------------------------------------- 1 | import * as Commonlib from './all'; 2 | import * as Betalib from './betalib'; 3 | 4 | Commonlib.runIfEnableAndLoad('dropdown-problem', async () => { 5 | const isHoverEnable = await Commonlib.isEnable('dropdown-hover'); 6 | const tabs = $('#main-container .nav > li'); 7 | for (let i = 0; i < tabs.length; ++i) { 8 | const $li = tabs.eq(i); 9 | const $a = $('a', $li); 10 | // Problemタブか 11 | if ($a.length > 0 && ($a.attr('href') as string).match(/\/tasks\/?$/)) { 12 | const $ul = $('