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 +107 -0
- candidate/config.py +127 -0
- candidate/fetchers.py +217 -0
- candidate/main.py +187 -0
- candidate/network.py +22 -0
- candidate/tracker.py +54 -0
- candidate/ui.py +253 -0
- candidate-0.1.0.dist-info/METADATA +8 -0
- candidate-0.1.0.dist-info/RECORD +12 -0
- candidate-0.1.0.dist-info/WHEEL +5 -0
- candidate-0.1.0.dist-info/entry_points.txt +2 -0
- candidate-0.1.0.dist-info/top_level.txt +1 -0
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,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 @@
|
|
|
1
|
+
candidate
|