candidate 0.1.0__py3-none-any.whl

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/checkers.py ADDED
@@ -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
+
candidate/config.py ADDED
@@ -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"
candidate/fetchers.py ADDED
@@ -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
candidate/main.py ADDED
@@ -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()
candidate/network.py ADDED
@@ -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
candidate/tracker.py ADDED
@@ -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())
candidate/ui.py ADDED
@@ -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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: candidate
3
+ Version: 0.1.0
4
+ Summary: CLI tool to practice Competitive Programming and DSA
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: browser-cookie3
7
+ Requires-Dist: python-dotenv
8
+ Requires-Dist: requests
@@ -0,0 +1,12 @@
1
+ candidate/checkers.py,sha256=SgoFYBeCFceEpP7Ip4qCsD407sGLalWcLYT4HmFog4I,3390
2
+ candidate/config.py,sha256=V_2B7sMXV5AMZrr5RG0tPyUuLU8BQ4M_Bj-x2jS7pEk,3766
3
+ candidate/fetchers.py,sha256=VO51D58uPbUvlK7Fn3SdKIW2GqN6CG9NUflCdZ9AQT4,7436
4
+ candidate/main.py,sha256=Gfx2hygmUIp-LF96CogO8q4bnxlc4WX9Un7GKit0fZw,5708
5
+ candidate/network.py,sha256=ulRyZKTKeRpTUkArBGTdUfdvyywENiwNvz3UWgB1e4Y,714
6
+ candidate/tracker.py,sha256=IXVfExpvYhGGTLrfPkmCUlz9-cEUzMivWrvElkNQJDw,1721
7
+ candidate/ui.py,sha256=gpCXYGpAtaQ6-1omroMOaUcb3q79fhAxXRbRteMCbC4,9773
8
+ candidate-0.1.0.dist-info/METADATA,sha256=2e9VG8hau01uGXHj3TCOyYI6DktTP5_pAa6oPZtNMx8,223
9
+ candidate-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ candidate-0.1.0.dist-info/entry_points.txt,sha256=QXrDXR1CtoOwcJMV7IIRsjB02meJ-dN4NIs5I9Z23n0,49
11
+ candidate-0.1.0.dist-info/top_level.txt,sha256=HoEnDxpH3OIqLkmFJQx0suM3RENzTxSSsD6izSr07Eg,10
12
+ candidate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ candidate = candidate.main:run
@@ -0,0 +1 @@
1
+ candidate