candidate 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- candidate-0.1.0/PKG-INFO +8 -0
- candidate-0.1.0/README.md +137 -0
- candidate-0.1.0/pyproject.toml +21 -0
- candidate-0.1.0/setup.cfg +4 -0
- candidate-0.1.0/src/candidate/checkers.py +107 -0
- candidate-0.1.0/src/candidate/config.py +127 -0
- candidate-0.1.0/src/candidate/fetchers.py +217 -0
- candidate-0.1.0/src/candidate/main.py +187 -0
- candidate-0.1.0/src/candidate/network.py +22 -0
- candidate-0.1.0/src/candidate/tracker.py +54 -0
- candidate-0.1.0/src/candidate/ui.py +253 -0
- candidate-0.1.0/src/candidate.egg-info/PKG-INFO +8 -0
- candidate-0.1.0/src/candidate.egg-info/SOURCES.txt +15 -0
- candidate-0.1.0/src/candidate.egg-info/dependency_links.txt +1 -0
- candidate-0.1.0/src/candidate.egg-info/entry_points.txt +2 -0
- candidate-0.1.0/src/candidate.egg-info/requires.txt +3 -0
- candidate-0.1.0/src/candidate.egg-info/top_level.txt +1 -0
candidate-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# candidate
|
|
2
|
+
|
|
3
|
+
A CLI tool for competitive programmers to run timed virtual contests using problems from **Codeforces**, **AtCoder**, and **LeetCode**. Problems are pulled from your unsolved pool and tracked in real time as you submit.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Some notable features
|
|
8
|
+
|
|
9
|
+
- **Virtual contests** on Codeforces, AtCoder, LeetCode, or a mixed set
|
|
10
|
+
- Provides **tier-based problem selection**: 50% of problems are drawn from tier 1 (easier) and 50% from tier 2 (harder).
|
|
11
|
+
- The tool polls each platform and detects accepted submissions automatically; the contest ends as soon as all problems are solved.
|
|
12
|
+
- It will also auto-detect your session cookies, the tool reads your browser's stored session for AtCoder and LeetCode automatically (Chrome, Brave, Firefox, Safari, Edge, Opera are supported as of now, make sure you use one of these browsers)
|
|
13
|
+
- It supports **tag filtering** (`dp`, `graphs`). Not available in mixed (`x`) mode yet.
|
|
14
|
+
- You can adjust problems by **difficulty ranges and recency** via a `config.toml` file
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Python 3.11+
|
|
21
|
+
- For **LeetCode** and **AtCoder**: an account on the respective platform and at least one supported browser installed and logged in (Chrome, Brave, Firefox, Safari, Edge, or Opera)
|
|
22
|
+
- For **Codeforces**: only your handle is needed, no login required
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Configuration
|
|
27
|
+
|
|
28
|
+
Create a `config.toml` file in the project root (in the same directory as `config.example.toml`). Defaults are listed below.
|
|
29
|
+
|
|
30
|
+
```toml
|
|
31
|
+
|
|
32
|
+
# don't leave these empty
|
|
33
|
+
|
|
34
|
+
[handles]
|
|
35
|
+
codeforces = "your_cf_handle"
|
|
36
|
+
atcoder = "your_ac_handle"
|
|
37
|
+
leetcode = "your_lc_handle"
|
|
38
|
+
|
|
39
|
+
[codeforces]
|
|
40
|
+
tier1 = [1000, 1500] # rating range for easier problems
|
|
41
|
+
tier2 = [1600, 1900] # rating range for harder problems
|
|
42
|
+
recent_max = 1900 # only include problems from contests >= given number
|
|
43
|
+
|
|
44
|
+
[atcoder]
|
|
45
|
+
tier1 = [1000, 1200]
|
|
46
|
+
tier2 = [1300, 1800]
|
|
47
|
+
recent_max = 320 # only include problems from ABC/ARC/AGC contests >= given number
|
|
48
|
+
|
|
49
|
+
[leetcode]
|
|
50
|
+
difficulties = ["MEDIUM", "HARD"] # valid values: "EASY", "MEDIUM", "HARD"
|
|
51
|
+
recent_max = 2500 # only include problems with number >= given number
|
|
52
|
+
|
|
53
|
+
[display]
|
|
54
|
+
show_difficulties = false # show difficulty label next to each problem title
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If a field is omitted, the following defaults apply:
|
|
58
|
+
|
|
59
|
+
|Key|Default|
|
|
60
|
+
|---|---|
|
|
61
|
+
|`handles.codeforces`|`""`|
|
|
62
|
+
|`handles.atcoder`|`""`|
|
|
63
|
+
|`handles.leetcode`|`""`|
|
|
64
|
+
|`codeforces.tier1`|`[1000, 1500]`|
|
|
65
|
+
|`codeforces.tier2`|`[1600, 1900]`|
|
|
66
|
+
|`codeforces.recent_max`|`1900`|
|
|
67
|
+
|`atcoder.tier1`|`[1000, 1200]`|
|
|
68
|
+
|`atcoder.tier2`|`[1300, 1800]`|
|
|
69
|
+
|`atcoder.recent_max`|`320`|
|
|
70
|
+
|`leetcode.difficulties`|`["MEDIUM", "HARD"]`|
|
|
71
|
+
|`leetcode.recent_max`|`2500`|
|
|
72
|
+
|`display.show_difficulties`|`false`|
|
|
73
|
+
|
|
74
|
+
You probably don't want to leave all handles empty. Enter at least one of them.
|
|
75
|
+
|
|
76
|
+
### `config.toml` options
|
|
77
|
+
|
|
78
|
+
**`[handles]`**: Your usernames on each platform. Used to filter out problems you've already solved and to check submissions during a contest.
|
|
79
|
+
|
|
80
|
+
**`[codeforces]`**
|
|
81
|
+
|
|
82
|
+
- `tier1` / `tier2`: Rating ranges that define the two difficulty buckets. The contest sampler draws 50% of problems from tier 1 and 50% from tier 2.
|
|
83
|
+
- `recent_max`: Filters the problem pool to only include problems from contests with an ID at or above this value.
|
|
84
|
+
|
|
85
|
+
**`[atcoder]`**
|
|
86
|
+
|
|
87
|
+
- `tier1` / `tier2`: Same as Codeforces but uses AtCoder's internal difficulty score.
|
|
88
|
+
- `recent_max`: Only problems from ABC/ARC/AGC contests numbered at or above this value are included.
|
|
89
|
+
|
|
90
|
+
**`[leetcode]`**
|
|
91
|
+
|
|
92
|
+
- `difficulties`: Which difficulty levels to include in the pool. The sampler treats lower difficulties as tier 1 and higher as tier 2.
|
|
93
|
+
- `recent_max`: Only problems with a frontend ID at or above this value are included.
|
|
94
|
+
|
|
95
|
+
**`[display]`**
|
|
96
|
+
|
|
97
|
+
- `show_difficulties`: When `true`, each problem in the contest view shows its difficulty rating or label next to the title. Note that this is OFF by default.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Usage
|
|
102
|
+
|
|
103
|
+
Just run the tool and follow the interactive setup:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
candidate
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
You will be prompted for:
|
|
110
|
+
|
|
111
|
+
|Prompt|Description|
|
|
112
|
+
|---|---|
|
|
113
|
+
|**Platform**|`cf` (Codeforces), `ac` (AtCoder), `lc` (LeetCode), `x` (mixed)|
|
|
114
|
+
|**Time limit**|Contest duration in minutes|
|
|
115
|
+
|**Number of problems**|How many problems to include|
|
|
116
|
+
|**Tags**|Optional comma-separated topic filters (e.g. `dp,graphs`). Not supported in mixed mode|
|
|
117
|
+
|
|
118
|
+
After setup, a preview of the selected problems is shown. Press **Enter** to start the contest or **Ctrl+C** to abort.
|
|
119
|
+
|
|
120
|
+
During the contest:
|
|
121
|
+
|
|
122
|
+
- The timer and solve status update every second.
|
|
123
|
+
- Submissions are checked against the platform every 30 seconds.
|
|
124
|
+
- The contest ends automatically when all problems are accepted, or when time runs out.
|
|
125
|
+
- Press **Ctrl+C** at any time to quit early.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Session Cookies
|
|
130
|
+
|
|
131
|
+
AtCoder and LeetCode require an active session to fetch your submission history and verify solves. `candidate` auto-detects your session cookie from whichever supported browser you use. This requires you to be logged in on both platforms on any supported browser.
|
|
132
|
+
|
|
133
|
+
Detected cookies are cached in a `.env` file in the project root so subsequent runs are instant. If auto-detection fails (maybe you are not logged in, or the browser is unsupported), the tool will print a clear message explaining how to set the cookie manually in `.env`.
|
|
134
|
+
|
|
135
|
+
Supported browsers are: **Chrome**, **Brave**, **Firefox**, **Safari**, **Edge**, **Opera**
|
|
136
|
+
|
|
137
|
+
---
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "candidate"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI tool to practice Competitive Programming and DSA"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
"browser-cookie3",
|
|
13
|
+
"python-dotenv",
|
|
14
|
+
"requests",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
candidate = "candidate.main:run"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import re
|
|
3
|
+
import requests
|
|
4
|
+
from .network import req
|
|
5
|
+
from . import config
|
|
6
|
+
import os
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from .config import *
|
|
10
|
+
|
|
11
|
+
def _cf_solved_set():
|
|
12
|
+
data = req(f"https://codeforces.com/api/user.status?handle={config.CF_HANDLE}&from=1&count=15")
|
|
13
|
+
if not data or data.get("status") != "OK":
|
|
14
|
+
return set()
|
|
15
|
+
return {
|
|
16
|
+
f"{s['problem']['contestId']}-{s['problem']['index']}"
|
|
17
|
+
for s in data["result"]
|
|
18
|
+
if s.get("verdict") == "OK" and "contestId" in s["problem"]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
def _ac_solved_set(problems):
|
|
22
|
+
"""
|
|
23
|
+
Queries AtCoder's personal submission list page per active contest, filtered by AC.
|
|
24
|
+
Fires exactly one request per contest and safely isolates the table rows.
|
|
25
|
+
"""
|
|
26
|
+
solved = set()
|
|
27
|
+
|
|
28
|
+
contest_ids = set()
|
|
29
|
+
for p in problems:
|
|
30
|
+
if p.get("platform") == "ac":
|
|
31
|
+
|
|
32
|
+
parts = p["url"].split("/")
|
|
33
|
+
if len(parts) >= 5:
|
|
34
|
+
contest_ids.add(parts[4])
|
|
35
|
+
|
|
36
|
+
hdrs = {
|
|
37
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"
|
|
38
|
+
}
|
|
39
|
+
cookies = {"REVEL_SESSION": AC_SESSION_COOKIE}
|
|
40
|
+
|
|
41
|
+
# print(contest_ids)
|
|
42
|
+
# with open("output.txt", "w", encoding="utf-8") as file:
|
|
43
|
+
# file.write("\n".join(str(item) for item in contest_ids))
|
|
44
|
+
|
|
45
|
+
for contest_id in contest_ids:
|
|
46
|
+
|
|
47
|
+
url = f"https://atcoder.jp/contests/{contest_id}/submissions/me?f.Status=AC"
|
|
48
|
+
try:
|
|
49
|
+
r = requests.get(url, headers=hdrs, cookies=cookies, timeout=6)
|
|
50
|
+
if r.status_code == 200:
|
|
51
|
+
# print(r.text)
|
|
52
|
+
# with open("out.txt", "w", encoding="utf-8") as file:
|
|
53
|
+
# file.write(r.text)
|
|
54
|
+
table_match = re.search(r'<table[^>]*>([\s\S]*?)</table>', r.text)
|
|
55
|
+
if table_match:
|
|
56
|
+
table_html = table_match.group(1)
|
|
57
|
+
|
|
58
|
+
pids = re.findall(r'/tasks/([a-zA-Z0-9_]+)', table_html)
|
|
59
|
+
solved.update(pids)
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# print(solved)
|
|
64
|
+
return solved
|
|
65
|
+
|
|
66
|
+
def _lc_solved_set():
|
|
67
|
+
hdrs = {
|
|
68
|
+
"content-type": "application/json",
|
|
69
|
+
"cookie": f"LEETCODE_SESSION={LC_SESSION_COOKIE}",
|
|
70
|
+
"referer": "https://leetcode.com",
|
|
71
|
+
"x-csrftoken": "dummy",
|
|
72
|
+
}
|
|
73
|
+
q_recent = """
|
|
74
|
+
query recentAcSubmissions($username: String!, $limit: Int!) {
|
|
75
|
+
recentAcSubmissionList(username: $username, limit: $limit) {
|
|
76
|
+
titleSlug
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
"""
|
|
80
|
+
data = req("https://leetcode.com/graphql", method="POST",
|
|
81
|
+
headers=hdrs,
|
|
82
|
+
json_data={"query": q_recent, "variables": {"username": config.LC_HANDLE, "limit": 15}})
|
|
83
|
+
|
|
84
|
+
solved = set()
|
|
85
|
+
if data and not data.get("errors"):
|
|
86
|
+
for item in (data["data"].get("recentAcSubmissionList") or []):
|
|
87
|
+
solved.add(item["titleSlug"])
|
|
88
|
+
return solved
|
|
89
|
+
|
|
90
|
+
def check_solved(problems):
|
|
91
|
+
platforms = {p["platform"] for p in problems}
|
|
92
|
+
solved_sets = {}
|
|
93
|
+
|
|
94
|
+
if "cf" in platforms:
|
|
95
|
+
solved_sets["cf"] = _cf_solved_set()
|
|
96
|
+
if "ac" in platforms:
|
|
97
|
+
ac_probs = [p for p in problems if p["platform"] == "ac"]
|
|
98
|
+
solved_sets["ac"] = _ac_solved_set(ac_probs)
|
|
99
|
+
if "lc" in platforms:
|
|
100
|
+
solved_sets["lc"] = _lc_solved_set()
|
|
101
|
+
|
|
102
|
+
result = {}
|
|
103
|
+
for p in problems:
|
|
104
|
+
result[p["id"]] = p["id"] in solved_sets.get(p["platform"], set())
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import browser_cookie3
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from dotenv import load_dotenv, set_key
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
ENV_PATH = Path(__file__).parent.parent.parent / ".env"
|
|
8
|
+
load_dotenv(ENV_PATH)
|
|
9
|
+
|
|
10
|
+
BROWSER_LOADERS = [
|
|
11
|
+
("Chrome", browser_cookie3.chrome),
|
|
12
|
+
("Brave", browser_cookie3.brave),
|
|
13
|
+
("Firefox", browser_cookie3.firefox),
|
|
14
|
+
("Safari", browser_cookie3.safari),
|
|
15
|
+
("Edge", browser_cookie3.edge),
|
|
16
|
+
("Opera", browser_cookie3.opera),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
COOKIE_TARGETS = {
|
|
20
|
+
"LC_SESSION_COOKIE": {
|
|
21
|
+
"domain": "leetcode.com",
|
|
22
|
+
"cookie_name": "LEETCODE_SESSION",
|
|
23
|
+
"label": "LeetCode",
|
|
24
|
+
},
|
|
25
|
+
"AC_SESSION_COOKIE": {
|
|
26
|
+
"domain": "atcoder.jp",
|
|
27
|
+
"cookie_name": "REVEL_SESSION",
|
|
28
|
+
"label": "AtCoder",
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _detect_from_browsers(domain: str, cookie_name: str) -> tuple[str, str]:
|
|
34
|
+
for browser_name, loader in BROWSER_LOADERS:
|
|
35
|
+
try:
|
|
36
|
+
cj = loader(domain_name=domain)
|
|
37
|
+
all_cookies = [(c.name, c.value[:20] + "...") for c in cj]
|
|
38
|
+
print(f"[debug] {browser_name} cookies for {domain}: {all_cookies}")
|
|
39
|
+
|
|
40
|
+
value = next((c.value for c in cj if c.name == cookie_name), "")
|
|
41
|
+
if value:
|
|
42
|
+
return value, browser_name
|
|
43
|
+
except Exception:
|
|
44
|
+
continue
|
|
45
|
+
return "", ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_cookie(env_key: str) -> str:
|
|
49
|
+
target = COOKIE_TARGETS[env_key]
|
|
50
|
+
domain = target["domain"]
|
|
51
|
+
cookie_name = target["cookie_name"]
|
|
52
|
+
label = target["label"]
|
|
53
|
+
|
|
54
|
+
existing = os.environ.get(env_key, "")
|
|
55
|
+
if existing:
|
|
56
|
+
print(f"[cookies] {label}: loaded from .env")
|
|
57
|
+
return existing
|
|
58
|
+
|
|
59
|
+
print(f"[cookies] {label}: scanning browsers...", end=" ", flush=True)
|
|
60
|
+
value, browser_name = _detect_from_browsers(domain, cookie_name)
|
|
61
|
+
|
|
62
|
+
if value:
|
|
63
|
+
print(f"found in {browser_name}")
|
|
64
|
+
set_key(ENV_PATH, env_key, value)
|
|
65
|
+
os.environ[env_key] = value
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
print("not found")
|
|
69
|
+
print(
|
|
70
|
+
f"[cookies] WARNING: Could not auto-detect {label} session cookie.\n"
|
|
71
|
+
f" Log in to {domain.lstrip('.')} in any supported browser, then re-run.\n"
|
|
72
|
+
f" Or set {env_key} manually in your .env file."
|
|
73
|
+
)
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
LC_SESSION_COOKIE = get_cookie("LC_SESSION_COOKIE")
|
|
78
|
+
AC_SESSION_COOKIE = get_cookie("AC_SESSION_COOKIE")
|
|
79
|
+
|
|
80
|
+
CONFIG_PATH = Path(__file__).parent.parent.parent / "config.toml"
|
|
81
|
+
|
|
82
|
+
def _load() -> dict:
|
|
83
|
+
if not CONFIG_PATH.exists():
|
|
84
|
+
raise FileNotFoundError(
|
|
85
|
+
f"config.toml not found at {CONFIG_PATH}\n"
|
|
86
|
+
f"Copy config.example.toml to config.toml and fill in your details."
|
|
87
|
+
)
|
|
88
|
+
with open(CONFIG_PATH, "rb") as f:
|
|
89
|
+
return tomllib.load(f)
|
|
90
|
+
|
|
91
|
+
_cfg = _load()
|
|
92
|
+
_handles = _cfg.get("handles", {})
|
|
93
|
+
_cf = _cfg.get("codeforces", {})
|
|
94
|
+
_ac = _cfg.get("atcoder", {})
|
|
95
|
+
_lc = _cfg.get("leetcode", {})
|
|
96
|
+
_display = _cfg.get("display", {})
|
|
97
|
+
|
|
98
|
+
# Contest defaults (Don't change these)
|
|
99
|
+
DEFAULT_MINUTES = 120
|
|
100
|
+
NUM_PROBLEMS = 4
|
|
101
|
+
CHECK_INTERVAL = 30
|
|
102
|
+
|
|
103
|
+
# Handles
|
|
104
|
+
CF_HANDLE = _handles.get("codeforces", "")
|
|
105
|
+
AC_HANDLE = _handles.get("atcoder", "")
|
|
106
|
+
LC_HANDLE = _handles.get("leetcode", "")
|
|
107
|
+
|
|
108
|
+
# Codeforces
|
|
109
|
+
CF_T1 = tuple(_cf.get("tier1", [1000, 1500]))
|
|
110
|
+
CF_T2 = tuple(_cf.get("tier2", [1600, 1900]))
|
|
111
|
+
CF_RECENT = _cf.get("recent_max", 1900)
|
|
112
|
+
|
|
113
|
+
# AtCoder
|
|
114
|
+
AC_T1 = tuple(_ac.get("tier1", [1000, 1200]))
|
|
115
|
+
AC_T2 = tuple(_ac.get("tier2", [1300, 1800]))
|
|
116
|
+
AC_RECENT = _ac.get("recent_max", 320)
|
|
117
|
+
|
|
118
|
+
# LeetCode
|
|
119
|
+
LC_DIFFICULTIES = _lc.get("difficulties", ["MEDIUM", "HARD"])
|
|
120
|
+
LC_RECENT = _lc.get("recent_max", 2500)
|
|
121
|
+
|
|
122
|
+
# Display
|
|
123
|
+
SHOW_DIFFICULTY = _display.get("show_difficulties", False)
|
|
124
|
+
|
|
125
|
+
BAR_FULL = "█"
|
|
126
|
+
BAR_EMPTY = "░"
|
|
127
|
+
URGENCY = "critical"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from .network import req
|
|
3
|
+
from . import config
|
|
4
|
+
import re
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
def cf_fetch_problems(tags=None):
|
|
8
|
+
solved_data = req(f"https://codeforces.com/api/user.status?handle={config.CF_HANDLE}&from=1&count=10000")
|
|
9
|
+
solved = set()
|
|
10
|
+
if solved_data and solved_data.get("status") == "OK":
|
|
11
|
+
for s in solved_data["result"]:
|
|
12
|
+
if s.get("verdict") == "OK" and "contestId" in s["problem"]:
|
|
13
|
+
solved.add(f"{s['problem']['contestId']}-{s['problem']['index']}")
|
|
14
|
+
|
|
15
|
+
pool_data = req("https://codeforces.com/api/problemset.problems")
|
|
16
|
+
if not pool_data or pool_data.get("status") != "OK":
|
|
17
|
+
return [], []
|
|
18
|
+
|
|
19
|
+
t1, t2 = [], []
|
|
20
|
+
for p in pool_data["result"]["problems"]:
|
|
21
|
+
if p.get("contestId", 0) < config.CF_RECENT:
|
|
22
|
+
continue
|
|
23
|
+
rating = p.get("rating")
|
|
24
|
+
if rating is None:
|
|
25
|
+
continue
|
|
26
|
+
pid = f"{p['contestId']}-{p['index']}"
|
|
27
|
+
if pid in solved:
|
|
28
|
+
continue
|
|
29
|
+
if tags and not set(tags).intersection(set(p.get("tags", []))):
|
|
30
|
+
continue
|
|
31
|
+
entry = {
|
|
32
|
+
"id": pid,
|
|
33
|
+
"title": f"{p['contestId']}{p['index']} - {p['name']}",
|
|
34
|
+
"url": f"https://codeforces.com/problemset/problem/{p['contestId']}/{p['index']}",
|
|
35
|
+
"difficulty": str(rating),
|
|
36
|
+
"platform": "cf",
|
|
37
|
+
}
|
|
38
|
+
if config.CF_T1[0] <= rating <= config.CF_T1[1]:
|
|
39
|
+
t1.append(entry)
|
|
40
|
+
elif config.CF_T2[0] <= rating <= config.CF_T2[1]:
|
|
41
|
+
t2.append(entry)
|
|
42
|
+
return t1, t2
|
|
43
|
+
|
|
44
|
+
def ac_fetch_problems(tags=None):
|
|
45
|
+
|
|
46
|
+
# will return a list of problems respecting specified rating and problem recent-ness
|
|
47
|
+
|
|
48
|
+
hdrs = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"}
|
|
49
|
+
|
|
50
|
+
# getting all submissions
|
|
51
|
+
solved_data = req(
|
|
52
|
+
f"https://kenkoooo.com/atcoder/atcoder-api/v3/user/submissions?user={config.AC_HANDLE}&from_second=0",
|
|
53
|
+
headers=hdrs,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
solved = set() # store solved problem ids
|
|
57
|
+
|
|
58
|
+
if solved_data:
|
|
59
|
+
solved = {s["problem_id"] for s in solved_data if s.get("result") == "AC"}
|
|
60
|
+
try:
|
|
61
|
+
# this won't work, atcoder doesn't allow viewing submissions so ignore this
|
|
62
|
+
live_url = f"https://atcoder.jp/submissions?f.User={config.AC_HANDLE}&f.Status=AC"
|
|
63
|
+
|
|
64
|
+
r = requests.get(live_url, headers=hdrs, timeout=10)
|
|
65
|
+
if r.status_code == 200:
|
|
66
|
+
live_solved = re.findall(r"/contests/[^/]+/tasks/([a-zA-Z0-9_]+)", r.text)
|
|
67
|
+
solved.update(live_solved)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
probs = req("https://kenkoooo.com/atcoder/resources/problems.json", headers=hdrs) # for problems
|
|
73
|
+
models = req("https://kenkoooo.com/atcoder/resources/problem-models.json", headers=hdrs) # for rating of problems
|
|
74
|
+
|
|
75
|
+
if not probs or not models:
|
|
76
|
+
return [], []
|
|
77
|
+
|
|
78
|
+
t1, t2 = [], []
|
|
79
|
+
for p in probs:
|
|
80
|
+
pid = p["id"]
|
|
81
|
+
if pid in solved:
|
|
82
|
+
continue
|
|
83
|
+
parts = pid.split("_")
|
|
84
|
+
|
|
85
|
+
if parts and parts[0][:3] in ("abc", "arc", "agc"):
|
|
86
|
+
try:
|
|
87
|
+
if int(parts[0][3:]) < config.AC_RECENT:
|
|
88
|
+
continue
|
|
89
|
+
except ValueError:
|
|
90
|
+
continue
|
|
91
|
+
else:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
model = models.get(pid)
|
|
95
|
+
if not model or model.get("difficulty") is None:
|
|
96
|
+
continue
|
|
97
|
+
diff = model["difficulty"]
|
|
98
|
+
|
|
99
|
+
# our problem entry
|
|
100
|
+
entry = {
|
|
101
|
+
"id": pid,
|
|
102
|
+
"title": p["title"],
|
|
103
|
+
"url": f"https://atcoder.jp/contests/{p['contest_id']}/tasks/{pid}",
|
|
104
|
+
"difficulty": f"{diff:.0f}",
|
|
105
|
+
"platform": "ac",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if config.AC_T1[0] <= diff <= config.AC_T1[1]:
|
|
109
|
+
t1.append(entry)
|
|
110
|
+
elif config.AC_T2[0] <= diff <= config.AC_T2[1]:
|
|
111
|
+
t2.append(entry)
|
|
112
|
+
return t1, t2
|
|
113
|
+
|
|
114
|
+
def lc_fetch_problems(tags=None):
|
|
115
|
+
hdrs = {
|
|
116
|
+
"content-type": "application/json",
|
|
117
|
+
"cookie": f"LEETCODE_SESSION={config.LC_SESSION_COOKIE}",
|
|
118
|
+
}
|
|
119
|
+
query = """
|
|
120
|
+
query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) {
|
|
121
|
+
problemsetQuestionList: questionList(categorySlug: $categorySlug, limit: $limit, skip: $skip, filters: $filters) {
|
|
122
|
+
data { questionFrontendId title titleSlug difficulty status topicTags { slug } }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
"""
|
|
126
|
+
questions = []
|
|
127
|
+
lc_solved = set()
|
|
128
|
+
for page in range(30):
|
|
129
|
+
variables = {
|
|
130
|
+
"categorySlug": "",
|
|
131
|
+
"skip": page * 100,
|
|
132
|
+
"limit": 100,
|
|
133
|
+
"filters": {"orderBy": "FRONTEND_ID", "sortOrder": "DESCENDING", "premiumOnly": False},
|
|
134
|
+
}
|
|
135
|
+
data = req("https://leetcode.com/graphql", method="POST",
|
|
136
|
+
headers=hdrs, json_data={"query": query, "variables": variables})
|
|
137
|
+
if not data or "errors" in data:
|
|
138
|
+
break
|
|
139
|
+
batch = data["data"]["problemsetQuestionList"]["data"]
|
|
140
|
+
if not batch:
|
|
141
|
+
break
|
|
142
|
+
for q in batch:
|
|
143
|
+
if q.get("status") == "ac":
|
|
144
|
+
lc_solved.add(q["titleSlug"])
|
|
145
|
+
questions.extend(batch)
|
|
146
|
+
try:
|
|
147
|
+
if batch and int(batch[-1]["questionFrontendId"]) < config.LC_RECENT:
|
|
148
|
+
break
|
|
149
|
+
except (ValueError, KeyError):
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
allowed = {d.upper() for d in config.LC_DIFFICULTIES}
|
|
153
|
+
pool = []
|
|
154
|
+
for q in questions:
|
|
155
|
+
if q["titleSlug"] in lc_solved:
|
|
156
|
+
continue
|
|
157
|
+
if q.get("status") == "ac":
|
|
158
|
+
continue
|
|
159
|
+
try:
|
|
160
|
+
if int(q["questionFrontendId"]) < config.LC_RECENT:
|
|
161
|
+
continue
|
|
162
|
+
except ValueError:
|
|
163
|
+
continue
|
|
164
|
+
if q["difficulty"].upper() not in allowed:
|
|
165
|
+
continue
|
|
166
|
+
if tags:
|
|
167
|
+
problem_tags = {t["slug"] for t in q.get("topicTags", [])}
|
|
168
|
+
if not set(tags).intersection(problem_tags):
|
|
169
|
+
continue
|
|
170
|
+
pool.append({
|
|
171
|
+
"id": q["titleSlug"],
|
|
172
|
+
"title": f"{q['questionFrontendId']}. {q['title']}",
|
|
173
|
+
"url": f"https://leetcode.com/problems/{q['titleSlug']}/",
|
|
174
|
+
"difficulty": q["difficulty"],
|
|
175
|
+
"platform": "lc",
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
difficulty_rank = {"EASY": 0, "MEDIUM": 1, "HARD": 2}
|
|
179
|
+
sorted_diffs = sorted(allowed, key=lambda d: difficulty_rank.get(d, 1))
|
|
180
|
+
mid = len(sorted_diffs) // 2
|
|
181
|
+
|
|
182
|
+
t1_diffs = set(sorted_diffs[:max(1, mid)])
|
|
183
|
+
t2_diffs = set(sorted_diffs[max(1, mid):])
|
|
184
|
+
if not t2_diffs:
|
|
185
|
+
t2_diffs = t1_diffs
|
|
186
|
+
t1_diffs = set()
|
|
187
|
+
|
|
188
|
+
t1 = [p for p in pool if p["difficulty"].upper() in t1_diffs]
|
|
189
|
+
t2 = [p for p in pool if p["difficulty"].upper() in t2_diffs]
|
|
190
|
+
return t1, t2
|
|
191
|
+
|
|
192
|
+
def mixed_fetch_problems(tags=None):
|
|
193
|
+
t1, t2 = [], []
|
|
194
|
+
for fn in (lc_fetch_problems, cf_fetch_problems):
|
|
195
|
+
a, b = fn(tags)
|
|
196
|
+
t1 += a
|
|
197
|
+
t2 += b
|
|
198
|
+
return t1, t2
|
|
199
|
+
|
|
200
|
+
def sample_problems(t1, t2, total):
|
|
201
|
+
|
|
202
|
+
# get 'total' number of problems
|
|
203
|
+
|
|
204
|
+
t2_q = round(total * 0.5) # adjust to change difficulty of later problems
|
|
205
|
+
t1_q = total - t2_q
|
|
206
|
+
|
|
207
|
+
sel2 = random.sample(t2, min(t2_q, len(t2)))
|
|
208
|
+
|
|
209
|
+
t1_q += max(0, t2_q - len(sel2))
|
|
210
|
+
|
|
211
|
+
sel1 = random.sample(t1, min(t1_q, len(t1)))
|
|
212
|
+
|
|
213
|
+
rem = total - len(sel1) - len(sel2)
|
|
214
|
+
if rem > 0:
|
|
215
|
+
extra_pool = [p for p in t2 if p not in sel2]
|
|
216
|
+
sel2 += random.sample(extra_pool, min(rem, len(extra_pool)))
|
|
217
|
+
return sel1 + sel2
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
import subprocess
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from . import config
|
|
7
|
+
from .ui import (
|
|
8
|
+
tw, clr, clear_screen, draw_banner, draw_contest, draw_done,
|
|
9
|
+
platform_badge, diff_colour, hyperlink, fmt, C
|
|
10
|
+
)
|
|
11
|
+
from .fetchers import (
|
|
12
|
+
cf_fetch_problems, ac_fetch_problems, lc_fetch_problems,
|
|
13
|
+
mixed_fetch_problems, sample_problems
|
|
14
|
+
)
|
|
15
|
+
from .tracker import SolveTracker
|
|
16
|
+
|
|
17
|
+
def send_notification(msg_title, msg_body):
|
|
18
|
+
try:
|
|
19
|
+
subprocess.Popen(["notify-send", "--urgency", config.URGENCY,
|
|
20
|
+
"--app-name", "CP Timer", msg_title, msg_body])
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def prompt_setup():
|
|
25
|
+
width = tw()
|
|
26
|
+
clear_screen()
|
|
27
|
+
print()
|
|
28
|
+
draw_banner(width)
|
|
29
|
+
print()
|
|
30
|
+
|
|
31
|
+
print(clr(" CONTEST SETUP", C.WHITE, C.BOLD))
|
|
32
|
+
print(clr(" " + "─" * 40, C.GREY))
|
|
33
|
+
print()
|
|
34
|
+
|
|
35
|
+
def ask(prompt, default=None):
|
|
36
|
+
suffix = f" [{default}]" if default is not None else ""
|
|
37
|
+
val = input(clr(f" {prompt}{suffix}: ", C.CYAN)).strip()
|
|
38
|
+
return val if val else str(default)
|
|
39
|
+
|
|
40
|
+
platform = ask("Platform (cf / ac / lc / x for mixed)", "lc").lower()
|
|
41
|
+
while platform not in ("cf", "ac", "lc", "x"):
|
|
42
|
+
print(clr(" Invalid. Choose cf, ac, lc, or x.", C.RED))
|
|
43
|
+
platform = ask("Platform", "lc").lower()
|
|
44
|
+
|
|
45
|
+
mins_raw = ask("Time limit (minutes)", config.DEFAULT_MINUTES)
|
|
46
|
+
try:
|
|
47
|
+
total = int(mins_raw) * 60
|
|
48
|
+
except ValueError:
|
|
49
|
+
total = config.DEFAULT_MINUTES * 60
|
|
50
|
+
|
|
51
|
+
num_raw = ask("Number of problems", config.NUM_PROBLEMS)
|
|
52
|
+
try:
|
|
53
|
+
num = int(num_raw)
|
|
54
|
+
except ValueError:
|
|
55
|
+
num = config.NUM_PROBLEMS
|
|
56
|
+
|
|
57
|
+
tags_raw = ask("Filter tags (comma-separated, or leave blank)", "")
|
|
58
|
+
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
|
|
59
|
+
|
|
60
|
+
print()
|
|
61
|
+
print(clr(f" Fetching problems from {platform.upper()}...", C.YELLOW))
|
|
62
|
+
print()
|
|
63
|
+
|
|
64
|
+
if platform == "cf":
|
|
65
|
+
t1, t2 = cf_fetch_problems(tags)
|
|
66
|
+
elif platform == "ac":
|
|
67
|
+
t1, t2 = ac_fetch_problems(tags)
|
|
68
|
+
elif platform == "lc":
|
|
69
|
+
t1, t2 = lc_fetch_problems(tags)
|
|
70
|
+
else:
|
|
71
|
+
t1, t2 = mixed_fetch_problems(tags)
|
|
72
|
+
|
|
73
|
+
print(clr(f" Pool: {len(t1)} tier-1 + {len(t2)} tier-2 candidates", C.GREY))
|
|
74
|
+
|
|
75
|
+
if not t1 and not t2:
|
|
76
|
+
print(clr(" No problems found. Check handles / cookie / tags.", C.RED))
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
problems = sample_problems(t1, t2, num)
|
|
80
|
+
|
|
81
|
+
if not problems:
|
|
82
|
+
print(clr(" Could not sample enough problems.", C.RED))
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
print(clr(f" Selected {len(problems)} problems.", C.GREEN))
|
|
86
|
+
print()
|
|
87
|
+
|
|
88
|
+
clear_screen()
|
|
89
|
+
print()
|
|
90
|
+
draw_banner(width)
|
|
91
|
+
print()
|
|
92
|
+
print(clr(" CONTEST PREVIEW", C.WHITE, C.BOLD))
|
|
93
|
+
print(clr(" " + "─" * 40, C.GREY))
|
|
94
|
+
print()
|
|
95
|
+
print(clr(f" Platform : {platform.upper()}", C.GREY))
|
|
96
|
+
print(clr(f" Duration : {total // 60} min", C.GREY))
|
|
97
|
+
print(clr(f" Problems : {len(problems)}", C.GREY))
|
|
98
|
+
print()
|
|
99
|
+
print(clr(" " + "─" * 40, C.GREY))
|
|
100
|
+
|
|
101
|
+
for i, prob in enumerate(problems):
|
|
102
|
+
badge = platform_badge(prob["platform"])
|
|
103
|
+
title_link = hyperlink(prob["url"], prob["title"])
|
|
104
|
+
num_str = clr(f" {i+1:>2}.", C.GREY)
|
|
105
|
+
title = clr(title_link, C.WHITE)
|
|
106
|
+
diff = clr(f" {prob['difficulty']}", diff_colour(prob["difficulty"])) if config.SHOW_DIFFICULTY else ""
|
|
107
|
+
print(f"{num_str} {badge} {title}{diff}")
|
|
108
|
+
print()
|
|
109
|
+
print(clr(" " + "─" * 40, C.GREY))
|
|
110
|
+
print()
|
|
111
|
+
print(clr(" Press Enter to start the contest, Ctrl+C to abort.", C.CYAN))
|
|
112
|
+
print()
|
|
113
|
+
try:
|
|
114
|
+
input()
|
|
115
|
+
except KeyboardInterrupt:
|
|
116
|
+
print(clr("\n Aborted.\n", C.GREY))
|
|
117
|
+
sys.exit(0)
|
|
118
|
+
|
|
119
|
+
return problems, total
|
|
120
|
+
|
|
121
|
+
def run():
|
|
122
|
+
problems, total = prompt_setup()
|
|
123
|
+
|
|
124
|
+
start_dt = datetime.now()
|
|
125
|
+
end_dt = start_dt + timedelta(seconds=total)
|
|
126
|
+
|
|
127
|
+
tracker = SolveTracker(problems, start_dt)
|
|
128
|
+
tracker.start()
|
|
129
|
+
|
|
130
|
+
print("\033[?25l", end="", flush=True)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
while True:
|
|
134
|
+
elapsed = (datetime.now() - start_dt).total_seconds()
|
|
135
|
+
status, solve_times, last_check = tracker.snapshot()
|
|
136
|
+
|
|
137
|
+
width = tw()
|
|
138
|
+
clear_screen()
|
|
139
|
+
print()
|
|
140
|
+
draw_banner(width)
|
|
141
|
+
print()
|
|
142
|
+
|
|
143
|
+
frame = draw_contest(elapsed, total, start_dt, end_dt,
|
|
144
|
+
problems, status, solve_times, last_check, width)
|
|
145
|
+
print("\n".join(frame), flush=True)
|
|
146
|
+
|
|
147
|
+
if elapsed >= total:
|
|
148
|
+
send_notification("Contest Over", f"Time's up! {sum(status.values())}/{len(problems)} solved.")
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
if tracker.all_solved():
|
|
152
|
+
send_notification("Contest Complete!", f"All {len(problems)} problems solved in {fmt(elapsed)}!")
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
time.sleep(1)
|
|
156
|
+
|
|
157
|
+
# Final results
|
|
158
|
+
elapsed = (datetime.now() - start_dt).total_seconds()
|
|
159
|
+
status, solve_times, _ = tracker.snapshot()
|
|
160
|
+
tracker.stop()
|
|
161
|
+
|
|
162
|
+
clear_screen()
|
|
163
|
+
print()
|
|
164
|
+
draw_banner(tw())
|
|
165
|
+
print()
|
|
166
|
+
lines = draw_done(problems, solve_times, elapsed, total, tw())
|
|
167
|
+
print("\n".join(lines))
|
|
168
|
+
print("\a", end="", flush=True)
|
|
169
|
+
|
|
170
|
+
except KeyboardInterrupt:
|
|
171
|
+
elapsed = (datetime.now() - start_dt).total_seconds()
|
|
172
|
+
status, solve_times, _ = tracker.snapshot()
|
|
173
|
+
tracker.stop()
|
|
174
|
+
|
|
175
|
+
clear_screen()
|
|
176
|
+
print()
|
|
177
|
+
draw_banner(tw())
|
|
178
|
+
print()
|
|
179
|
+
lines = draw_done(problems, solve_times, elapsed, total, tw())
|
|
180
|
+
print("\n".join(lines))
|
|
181
|
+
print(clr("\n Quit early.\n", C.GREY))
|
|
182
|
+
|
|
183
|
+
finally:
|
|
184
|
+
print("\033[?25h", end="", flush=True) # restore cursor
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
run()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def req(url, method="GET", headers=None, json_data=None, retries=3):
|
|
6
|
+
for attempt in range(retries):
|
|
7
|
+
try:
|
|
8
|
+
if method == "POST":
|
|
9
|
+
r = requests.post(url, headers=headers, json=json_data, timeout=15)
|
|
10
|
+
else:
|
|
11
|
+
r = requests.get(url, headers=headers, timeout=12)
|
|
12
|
+
r.raise_for_status()
|
|
13
|
+
return r.json()
|
|
14
|
+
except requests.exceptions.HTTPError as e:
|
|
15
|
+
sc = e.response.status_code
|
|
16
|
+
if sc in (429, 500, 502, 503, 504):
|
|
17
|
+
time.sleep(2 ** attempt)
|
|
18
|
+
else:
|
|
19
|
+
return None
|
|
20
|
+
except Exception:
|
|
21
|
+
time.sleep(2 ** attempt)
|
|
22
|
+
return None
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from .checkers import check_solved
|
|
4
|
+
from .config import CHECK_INTERVAL
|
|
5
|
+
|
|
6
|
+
class SolveTracker:
|
|
7
|
+
def __init__(self, problems, start_dt):
|
|
8
|
+
self.problems = problems
|
|
9
|
+
self.start_dt = start_dt
|
|
10
|
+
self.status = {p["id"]: False for p in problems}
|
|
11
|
+
self.solve_times = {}
|
|
12
|
+
self.last_check = None
|
|
13
|
+
self._lock = threading.Lock()
|
|
14
|
+
self._stop = threading.Event()
|
|
15
|
+
|
|
16
|
+
def start(self):
|
|
17
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
18
|
+
self._thread.start()
|
|
19
|
+
|
|
20
|
+
def stop(self):
|
|
21
|
+
self._stop.set()
|
|
22
|
+
|
|
23
|
+
def _loop(self):
|
|
24
|
+
self._do_check()
|
|
25
|
+
while not self._stop.is_set():
|
|
26
|
+
slept = 0
|
|
27
|
+
while slept < CHECK_INTERVAL and not self._stop.is_set():
|
|
28
|
+
self._stop.wait(1)
|
|
29
|
+
slept += 1
|
|
30
|
+
if not self._stop.is_set():
|
|
31
|
+
self._do_check()
|
|
32
|
+
|
|
33
|
+
def _do_check(self):
|
|
34
|
+
check_started = datetime.now()
|
|
35
|
+
with self._lock:
|
|
36
|
+
self.last_check = check_started
|
|
37
|
+
try:
|
|
38
|
+
fresh = check_solved(self.problems)
|
|
39
|
+
elapsed = (datetime.now() - self.start_dt).total_seconds()
|
|
40
|
+
with self._lock:
|
|
41
|
+
for pid, solved in fresh.items():
|
|
42
|
+
if solved and not self.status[pid]:
|
|
43
|
+
self.solve_times[pid] = elapsed
|
|
44
|
+
self.status[pid] = solved
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def snapshot(self):
|
|
49
|
+
with self._lock:
|
|
50
|
+
return dict(self.status), dict(self.solve_times), self.last_check
|
|
51
|
+
|
|
52
|
+
def all_solved(self):
|
|
53
|
+
with self._lock:
|
|
54
|
+
return all(self.status.values())
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from .config import BAR_FULL, BAR_EMPTY, SHOW_DIFFICULTY, CHECK_INTERVAL
|
|
4
|
+
|
|
5
|
+
class C:
|
|
6
|
+
RESET = "\033[0m"
|
|
7
|
+
BOLD = "\033[1m"
|
|
8
|
+
DIM = "\033[2m"
|
|
9
|
+
WHITE = "\033[97m"
|
|
10
|
+
CYAN = "\033[96m"
|
|
11
|
+
GREEN = "\033[92m"
|
|
12
|
+
YELLOW = "\033[93m"
|
|
13
|
+
RED = "\033[91m"
|
|
14
|
+
GREY = "\033[90m"
|
|
15
|
+
BLUE = "\033[94m"
|
|
16
|
+
|
|
17
|
+
def clr(text, *codes):
|
|
18
|
+
return "".join(codes) + text + C.RESET
|
|
19
|
+
|
|
20
|
+
def hyperlink(url, text):
|
|
21
|
+
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
|
|
22
|
+
|
|
23
|
+
def tw():
|
|
24
|
+
return shutil.get_terminal_size((80, 24)).columns
|
|
25
|
+
|
|
26
|
+
def fmt(seconds):
|
|
27
|
+
seconds = max(0, int(seconds))
|
|
28
|
+
h, r = divmod(seconds, 3600)
|
|
29
|
+
m, s = divmod(r, 60)
|
|
30
|
+
return f"{h:02d}:{m:02d}:{s:02d}"
|
|
31
|
+
|
|
32
|
+
def clear_screen():
|
|
33
|
+
print("\033[2J\033[H", end="", flush=True)
|
|
34
|
+
|
|
35
|
+
BANNER = [
|
|
36
|
+
" ██████╗██████╗ ████████╗██╗███╗ ███╗███████╗██████╗ ",
|
|
37
|
+
" ██╔════╝██╔══██╗ ██╔══╝██║████╗ ████║██╔════╝██╔══██╗",
|
|
38
|
+
" ██║ ██████╔╝ ██║ ██║██╔████╔██║█████╗ ██████╔╝",
|
|
39
|
+
" ██║ ██╔═══╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██╗",
|
|
40
|
+
" ╚██████╗██║ ██║ ██║██║ ╚═╝ ██║███████╗██║ ██║",
|
|
41
|
+
" ╚═════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
def draw_banner(width):
|
|
45
|
+
bw = max(len(l) for l in BANNER)
|
|
46
|
+
pad = max(0, (width - bw) // 2)
|
|
47
|
+
for line in BANNER:
|
|
48
|
+
print(clr(" " * pad + line, C.CYAN, C.BOLD))
|
|
49
|
+
|
|
50
|
+
def progress_bar(elapsed, total, width):
|
|
51
|
+
ratio = min(elapsed / total, 1.0) if total else 1.0
|
|
52
|
+
filled = int(ratio * width)
|
|
53
|
+
colour = C.GREEN if ratio < 0.5 else (C.YELLOW if ratio < 0.8 else C.RED)
|
|
54
|
+
return clr(BAR_FULL * filled, colour, C.BOLD) + clr(BAR_EMPTY * (width - filled), C.GREY)
|
|
55
|
+
|
|
56
|
+
def diff_colour(diff_str):
|
|
57
|
+
d = diff_str.upper()
|
|
58
|
+
if d in ("EASY",) or (d.isdigit() and int(d) < 1200):
|
|
59
|
+
return C.GREEN
|
|
60
|
+
if d in ("MEDIUM",) or (d.isdigit() and 1200 <= int(d) < 1800):
|
|
61
|
+
return C.YELLOW
|
|
62
|
+
return C.RED
|
|
63
|
+
|
|
64
|
+
def platform_badge(platform):
|
|
65
|
+
badges = {"cf": clr(" CF ", C.BOLD, C.BLUE),
|
|
66
|
+
"ac": clr(" AC ", C.BOLD, C.CYAN),
|
|
67
|
+
"lc": clr(" LC ", C.BOLD, C.YELLOW)}
|
|
68
|
+
return badges.get(platform, platform.upper())
|
|
69
|
+
|
|
70
|
+
def draw_contest(elapsed, total, start_dt, end_dt,
|
|
71
|
+
problems, solve_status, solve_times, last_check, width):
|
|
72
|
+
remaining = max(0, total - elapsed)
|
|
73
|
+
ratio = min(elapsed / total, 1.0) if total else 1.0
|
|
74
|
+
|
|
75
|
+
if remaining <= 300:
|
|
76
|
+
clk_col = (C.RED, C.BOLD)
|
|
77
|
+
elif remaining <= 900:
|
|
78
|
+
clk_col = (C.YELLOW, C.BOLD)
|
|
79
|
+
else:
|
|
80
|
+
clk_col = (C.WHITE, C.BOLD)
|
|
81
|
+
|
|
82
|
+
inner_w = min(width - 4, 70)
|
|
83
|
+
box_w = inner_w + 4
|
|
84
|
+
lpad = max(0, (width - box_w) // 2)
|
|
85
|
+
ind = " " * lpad
|
|
86
|
+
border = clr("│", C.GREY)
|
|
87
|
+
|
|
88
|
+
def hline(): return ind + clr("┌" + "─" * (box_w - 2) + "┐", C.GREY)
|
|
89
|
+
def bline(): return ind + clr("└" + "─" * (box_w - 2) + "┘", C.GREY)
|
|
90
|
+
def mline(): return ind + clr("├" + "─" * (box_w - 2) + "┤", C.GREY)
|
|
91
|
+
|
|
92
|
+
def cline(s, raw=None):
|
|
93
|
+
vis = raw if raw is not None else len(s)
|
|
94
|
+
pad = inner_w - vis
|
|
95
|
+
lp = pad // 2
|
|
96
|
+
rp = pad - lp
|
|
97
|
+
return ind + border + " " + " " * lp + s + " " * rp + " " + border
|
|
98
|
+
|
|
99
|
+
def lline(s, raw=None):
|
|
100
|
+
vis = raw if raw is not None else len(s)
|
|
101
|
+
rpad = inner_w - vis
|
|
102
|
+
return ind + border + " " + s + " " * rpad + " " + border
|
|
103
|
+
|
|
104
|
+
out = []
|
|
105
|
+
out.append(hline())
|
|
106
|
+
rem_s = fmt(remaining)
|
|
107
|
+
out.append(cline(clr(rem_s, *clk_col), raw=len(rem_s)))
|
|
108
|
+
|
|
109
|
+
sub = f"elapsed {fmt(elapsed)} / total {fmt(total)}"
|
|
110
|
+
out.append(cline(clr(sub, C.GREY), raw=len(sub)))
|
|
111
|
+
out.append(mline())
|
|
112
|
+
|
|
113
|
+
bar = progress_bar(elapsed, total, inner_w)
|
|
114
|
+
out.append(ind + border + " " + bar + " " + border)
|
|
115
|
+
|
|
116
|
+
pct = f"{ratio*100:5.1f}%"
|
|
117
|
+
out.append(cline(clr(pct, C.DIM), raw=len(pct)))
|
|
118
|
+
out.append(mline())
|
|
119
|
+
|
|
120
|
+
ts = f"started {start_dt.strftime('%H:%M:%S')} ends {end_dt.strftime('%H:%M:%S')}"
|
|
121
|
+
out.append(cline(clr(ts, C.GREY), raw=len(ts)))
|
|
122
|
+
out.append(mline())
|
|
123
|
+
|
|
124
|
+
solved_count = sum(1 for p in problems if solve_status.get(p["id"]))
|
|
125
|
+
header = f" PROBLEMS {solved_count}/{len(problems)} solved"
|
|
126
|
+
out.append(lline(clr(header, C.WHITE, C.BOLD), raw=len(header)))
|
|
127
|
+
out.append(mline())
|
|
128
|
+
|
|
129
|
+
for i, prob in enumerate(problems):
|
|
130
|
+
pid = prob["id"]
|
|
131
|
+
is_done = solve_status.get(pid, False)
|
|
132
|
+
solved_t = solve_times.get(pid)
|
|
133
|
+
|
|
134
|
+
glyph = clr("[+]", C.GREEN, C.BOLD) if is_done else clr("[ ]", C.GREY)
|
|
135
|
+
badge = platform_badge(prob["platform"])
|
|
136
|
+
|
|
137
|
+
if is_done and solved_t:
|
|
138
|
+
right_str = f"+{fmt(solved_t)}"
|
|
139
|
+
right_disp = clr(right_str, C.GREEN)
|
|
140
|
+
right_raw = len(right_str)
|
|
141
|
+
elif SHOW_DIFFICULTY:
|
|
142
|
+
diff_str = prob["difficulty"]
|
|
143
|
+
right_str = f"{diff_str:>6}"
|
|
144
|
+
right_disp = clr(right_str, diff_colour(diff_str))
|
|
145
|
+
right_raw = len(right_str)
|
|
146
|
+
else:
|
|
147
|
+
right_str = ""
|
|
148
|
+
right_disp = ""
|
|
149
|
+
right_raw = 0
|
|
150
|
+
|
|
151
|
+
fixed_raw = 3 + 1 + 4 + 1
|
|
152
|
+
right_slot = right_raw + (2 if right_raw else 0)
|
|
153
|
+
max_title = inner_w - fixed_raw - right_slot
|
|
154
|
+
|
|
155
|
+
title_plain = prob["title"]
|
|
156
|
+
if len(title_plain) > max_title:
|
|
157
|
+
title_plain = title_plain[:max_title - 1] + "…"
|
|
158
|
+
|
|
159
|
+
title_linked = hyperlink(prob["url"], title_plain)
|
|
160
|
+
title_col = C.GREEN if is_done else C.WHITE
|
|
161
|
+
title_disp = clr(title_linked, title_col)
|
|
162
|
+
|
|
163
|
+
content_raw = len(title_plain)
|
|
164
|
+
pad = max(0, inner_w - fixed_raw - content_raw - right_slot)
|
|
165
|
+
|
|
166
|
+
line = (glyph + " " + badge + " " +
|
|
167
|
+
title_disp +
|
|
168
|
+
" " * pad +
|
|
169
|
+
(" " if right_raw else "") +
|
|
170
|
+
right_disp)
|
|
171
|
+
|
|
172
|
+
out.append(lline(line, raw=inner_w))
|
|
173
|
+
|
|
174
|
+
out.append(mline())
|
|
175
|
+
|
|
176
|
+
if last_check:
|
|
177
|
+
age = int((datetime.now() - last_check).total_seconds())
|
|
178
|
+
chk = f"last checked {age}s ago | polls every {CHECK_INTERVAL}s | Ctrl+C to quit"
|
|
179
|
+
else:
|
|
180
|
+
chk = f"waiting for first check... | Ctrl+C to quit"
|
|
181
|
+
out.append(cline(clr(chk, C.GREY), raw=len(chk)))
|
|
182
|
+
out.append(bline())
|
|
183
|
+
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
def draw_done(problems, solve_times, elapsed, total, width):
|
|
187
|
+
inner_w = min(width - 4, 70)
|
|
188
|
+
box_w = inner_w + 4
|
|
189
|
+
lpad = max(0, (width - box_w) // 2)
|
|
190
|
+
ind = " " * lpad
|
|
191
|
+
border = clr("│", C.GREY)
|
|
192
|
+
|
|
193
|
+
def hline(): return ind + clr("┌" + "─" * (box_w - 2) + "┐", C.GREY)
|
|
194
|
+
def bline(): return ind + clr("└" + "─" * (box_w - 2) + "┘", C.GREY)
|
|
195
|
+
def mline(): return ind + clr("├" + "─" * (box_w - 2) + "┤", C.GREY)
|
|
196
|
+
def cline(s, raw=None):
|
|
197
|
+
vis = raw if raw is not None else len(s)
|
|
198
|
+
pad = inner_w - vis
|
|
199
|
+
lp = pad // 2; rp = pad - lp
|
|
200
|
+
return ind + border + " " + " " * lp + s + " " * rp + " " + border
|
|
201
|
+
def lline(s, raw=None):
|
|
202
|
+
vis = raw if raw is not None else len(s)
|
|
203
|
+
return ind + border + " " + s + " " * max(0, inner_w - vis) + " " + border
|
|
204
|
+
|
|
205
|
+
solved_count = sum(1 for p in problems if solve_times.get(p["id"]) is not None)
|
|
206
|
+
all_done = solved_count == len(problems)
|
|
207
|
+
|
|
208
|
+
lines = []
|
|
209
|
+
lines.append(hline())
|
|
210
|
+
msg = "ALL PROBLEMS SOLVED" if all_done else "CONTEST OVER"
|
|
211
|
+
msg_col = C.GREEN if all_done else C.RED
|
|
212
|
+
lines.append(cline(clr(msg, msg_col, C.BOLD), raw=len(msg)))
|
|
213
|
+
sub = f"{solved_count}/{len(problems)} solved | {fmt(elapsed)} elapsed"
|
|
214
|
+
lines.append(cline(clr(sub, C.GREY), raw=len(sub)))
|
|
215
|
+
lines.append(mline())
|
|
216
|
+
|
|
217
|
+
if SHOW_DIFFICULTY:
|
|
218
|
+
hdr = f" {'#':>2} {'Problem':<33} {'Diff':>6} {'Time':>10}"
|
|
219
|
+
else:
|
|
220
|
+
hdr = f" {'#':>2} {'Problem':<42} {'Time':>10}"
|
|
221
|
+
lines.append(lline(clr(hdr, C.WHITE, C.BOLD), raw=len(hdr)))
|
|
222
|
+
lines.append(mline())
|
|
223
|
+
|
|
224
|
+
for i, prob in enumerate(problems):
|
|
225
|
+
pid = prob["id"]
|
|
226
|
+
st = solve_times.get(pid)
|
|
227
|
+
done = st is not None
|
|
228
|
+
t_str = f"+{fmt(st)}" if done else "unsolved"
|
|
229
|
+
t_col = C.GREEN if done else C.GREY
|
|
230
|
+
t_disp = clr(f"{t_str:>10}", t_col)
|
|
231
|
+
|
|
232
|
+
if SHOW_DIFFICULTY:
|
|
233
|
+
title = prob["title"]
|
|
234
|
+
if len(title) > 33: title = title[:32] + "…"
|
|
235
|
+
diff_disp = clr(f"{prob['difficulty']:>6}", diff_colour(prob["difficulty"]))
|
|
236
|
+
title_link = hyperlink(prob["url"], title)
|
|
237
|
+
title_disp = clr(title_link, C.WHITE if done else C.DIM)
|
|
238
|
+
row_plain = f" {i+1:>2} {title:<33} "
|
|
239
|
+
row_disp = clr(f" {i+1:>2} ", C.WHITE if done else C.DIM) + title_disp + clr(" ", C.RESET)
|
|
240
|
+
vis = len(row_plain) + 6 + 2 + 10
|
|
241
|
+
lines.append(lline(row_disp + diff_disp + clr(" ", C.RESET) + t_disp, raw=vis))
|
|
242
|
+
else:
|
|
243
|
+
title = prob["title"]
|
|
244
|
+
if len(title) > 42: title = title[:41] + "…"
|
|
245
|
+
title_link = hyperlink(prob["url"], title)
|
|
246
|
+
title_disp = clr(title_link, C.WHITE if done else C.DIM)
|
|
247
|
+
row_plain = f" {i+1:>2} {title:<42} "
|
|
248
|
+
row_disp = clr(f" {i+1:>2} ", C.WHITE if done else C.DIM) + title_disp + clr(" ", C.RESET)
|
|
249
|
+
vis = len(row_plain) + 10
|
|
250
|
+
lines.append(lline(row_disp + t_disp, raw=vis))
|
|
251
|
+
|
|
252
|
+
lines.append(bline())
|
|
253
|
+
return lines
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/candidate/checkers.py
|
|
4
|
+
src/candidate/config.py
|
|
5
|
+
src/candidate/fetchers.py
|
|
6
|
+
src/candidate/main.py
|
|
7
|
+
src/candidate/network.py
|
|
8
|
+
src/candidate/tracker.py
|
|
9
|
+
src/candidate/ui.py
|
|
10
|
+
src/candidate.egg-info/PKG-INFO
|
|
11
|
+
src/candidate.egg-info/SOURCES.txt
|
|
12
|
+
src/candidate.egg-info/dependency_links.txt
|
|
13
|
+
src/candidate.egg-info/entry_points.txt
|
|
14
|
+
src/candidate.egg-info/requires.txt
|
|
15
|
+
src/candidate.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
candidate
|