cf-tool-cli 1.0.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.
@@ -0,0 +1,767 @@
1
+ """All Codeforces HTML/JSON parsing in one place (regexes + BeautifulSoup).
2
+
3
+ Samples come in the classic markup (lines split by <br>), the newer one
4
+ (a <div class="test-example-line"> per line), and acmsguru's free-form
5
+ "Sample Input" statements.
6
+
7
+ Also here: recognizing the pages that stand *between* us and Codeforces
8
+ (Cloudflare's interstitial, Codeforces' own Turnstile gate and browser check).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import html as html_lib
14
+ import re
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timedelta, timezone
17
+
18
+ from bs4 import BeautifulSoup, Comment
19
+
20
+ from cf_tool.errors import CFError, NotLoggedInError
21
+
22
+ _HANDLE_RE = re.compile(r'handle = "([\s\S]+?)"')
23
+ _CSRF_RE = re.compile(r"csrf='(.+?)'")
24
+ _CSRF_DATA_RE = re.compile(r'data-csrf=[\'"]([a-f0-9]+)[\'"]')
25
+ _INPUT_RE = re.compile(r'class="input"[\s\S]*?<pre[^>]*>([\s\S]*?)</pre>')
26
+ _OUTPUT_RE = re.compile(r'class="output"[\s\S]*?<pre[^>]*>([\s\S]*?)</pre>')
27
+ # acmsguru statements are free-form: "Sample Input" <p>, then a <pre>
28
+ _SGU_INPUT_RE = re.compile(r"Sample\s+Input[\s\S]*?<pre[^>]*>([\s\S]*?)</pre>", re.IGNORECASE)
29
+ _SGU_OUTPUT_RE = re.compile(r"Sample\s+Output[\s\S]*?<pre[^>]*>([\s\S]*?)</pre>", re.IGNORECASE)
30
+ _COUNTDOWN_RE = re.compile(r'class=["\']countdown["\'][\s\S]*?(\d+):(\d+):(\d+)')
31
+ _ERROR_MSG_RE = re.compile(r'error[a-zA-Z_\- ]*">(.*?)</span>')
32
+ _SHOW_MSG_RE = re.compile(
33
+ r'Codeforces\.showMessage\("([^"]*)"\);\s*?Codeforces\.reformatTimes\(\);'
34
+ )
35
+ _CODE_RE = re.compile(r"<pre[\s\S]*?>([\s\S]*?)</pre>")
36
+ _UTC_OFFSET_RE = re.compile(r'name="utc_offset" content="([\s\S]+?)"')
37
+ _TAG_RE = re.compile(r"<[^>]+>")
38
+ _NUM_RE = re.compile(r"\d+")
39
+
40
+ # Codeforces has added classes over time, and the wording varies by problem
41
+ # age ("standard input" on newer pages, "stdin" on older ones): match loosely
42
+ _STANDARD_INPUT_RE = re.compile(
43
+ r'class="input-file input-standard"|'
44
+ r'class="input-file[^"]*"><div class="property-title">input</div>\s*(standard input|stdin)<'
45
+ )
46
+ _STANDARD_OUTPUT_RE = re.compile(
47
+ r'class="output-file output-standard"|'
48
+ r'class="output-file[^"]*"><div class="property-title">output</div>\s*(standard output|stdout)<'
49
+ )
50
+
51
+ # Cloudflare's managed-challenge interstitial. Never use "challenge-platform"
52
+ # as a marker: every normal page embeds Cloudflare's bot-detection script
53
+ # from /cdn-cgi/challenge-platform/scripts/jsd/.
54
+ _CLOUDFLARE_MARKERS = ("<title>Just a moment", "_cf_chl_opt")
55
+ # Codeforces' own Turnstile gate posts the solved token here, then reloads
56
+ _VERIFICATION_MARKER = "/data/turnstile"
57
+ # Codeforces' own check in front of the login page: a script that sets a cookie
58
+ # and reloads, so only a browser gets past it
59
+ _BROWSER_CHECK_MARKER = "Your browser is being checked"
60
+ _CAPTCHA_WORDS_RE = re.compile(r"captcha|robot|human|turnstile|verif", re.IGNORECASE)
61
+
62
+
63
+ def is_cloudflare_challenge(body: str) -> bool:
64
+ return any(marker in body for marker in _CLOUDFLARE_MARKERS)
65
+
66
+
67
+ def is_verification_gate(body: str) -> bool:
68
+ return _VERIFICATION_MARKER in body
69
+
70
+
71
+ def is_gate(body: str) -> bool:
72
+ """True for a page that stands in front of the content: Cloudflare's
73
+ interstitial, Codeforces' own Turnstile verification page or browser check."""
74
+ return is_cloudflare_challenge(body) or is_verification_gate(body) or is_browser_check(body)
75
+
76
+
77
+ _PAGE_TITLE_RE = re.compile(r"<title[^>]*>([\s\S]*?)</title>", re.IGNORECASE)
78
+
79
+
80
+ def describe_page(body: str) -> str:
81
+ """What a page is, in a few words, for an error message."""
82
+ if is_cloudflare_challenge(body):
83
+ return "Cloudflare's check"
84
+ if is_browser_check(body):
85
+ return "Codeforces' browser check"
86
+ if is_verification_gate(body):
87
+ return "Codeforces' Turnstile check"
88
+ match = _PAGE_TITLE_RE.search(body)
89
+ title = " ".join(html_lib.unescape(match.group(1)).split()) if match else ""
90
+ if title:
91
+ return f'the page "{title}"'
92
+ return "a blank page" if not body.strip() else "a page without a title"
93
+
94
+
95
+ def has_turnstile(body: str) -> bool:
96
+ """True when the page carries a Turnstile widget (only a browser can solve it)."""
97
+ return "cf-turnstile" in body
98
+
99
+
100
+ def is_browser_check(body: str) -> bool:
101
+ """True for Codeforces' "your browser is being checked" page."""
102
+ return _BROWSER_CHECK_MARKER in body
103
+
104
+
105
+ def is_captcha_error(message: str) -> bool:
106
+ """True when a form error is about the human check, not the credentials."""
107
+ return bool(_CAPTCHA_WORDS_RE.search(message))
108
+
109
+
110
+ def find_handle(body: str) -> str | None:
111
+ match = _HANDLE_RE.search(body)
112
+ return match.group(1) if match else None
113
+
114
+
115
+ def require_handle(body: str) -> str:
116
+ handle = find_handle(body)
117
+ if handle is None:
118
+ raise NotLoggedInError()
119
+ return handle
120
+
121
+
122
+ def wants_login(body: str) -> bool:
123
+ """Whether the page is the login form (a page Codeforces shows only once logged in)."""
124
+ return 'name="handleOrEmail"' in body or 'id="enterForm"' in body
125
+
126
+
127
+ def find_csrf(body: str) -> str:
128
+ match = _CSRF_RE.search(body) or _CSRF_DATA_RE.search(body)
129
+ if not match:
130
+ raise CFError("Cannot find csrf")
131
+ return match.group(1)
132
+
133
+
134
+ def _pre_to_text(inner: str) -> str:
135
+ """Sample <pre> content -> text. Handles both the classic <br>-separated
136
+ markup and the newer one-<div>-per-line markup."""
137
+ if "test-example-line" in inner:
138
+ lines = re.findall(r"<div[^>]*>([\s\S]*?)</div>", inner)
139
+ # only the whole text is trimmed: leading spaces inside
140
+ # a line can be part of the input, e.g. ASCII-art grids
141
+ text = "\n".join(html_lib.unescape(_TAG_RE.sub("", line)) for line in lines)
142
+ return text.strip() + "\n"
143
+ text = re.sub(r"<[\s/br]+?>", "\n", inner) # <br> in all its spellings
144
+ text = _TAG_RE.sub("", text)
145
+ return html_lib.unescape(text).strip() + "\n"
146
+
147
+
148
+ def find_samples(body: str) -> tuple[list[str], list[str]]:
149
+ """Sample (inputs, outputs) as text, each ending with a newline."""
150
+ inputs = _INPUT_RE.findall(body)
151
+ outputs = _OUTPUT_RE.findall(body)
152
+ if not inputs and not outputs:
153
+ inputs = _SGU_INPUT_RE.findall(body)
154
+ outputs = _SGU_OUTPUT_RE.findall(body)
155
+ if not inputs or not outputs or len(inputs) != len(outputs):
156
+ raise CFError(f"Cannot parse sample with input {len(inputs)} and output {len(outputs)}")
157
+ return [_pre_to_text(s) for s in inputs], [_pre_to_text(s) for s in outputs]
158
+
159
+
160
+ _TITLE_RE = re.compile(r'class="header"><div class="title">([\s\S]*?)</div>')
161
+ _TIME_LIMIT_RE = re.compile(
162
+ r'class="time-limit"><div class="property-title">[^<]*</div>\s*([\d.]+)'
163
+ )
164
+ _MEMORY_LIMIT_RE = re.compile(
165
+ r'class="memory-limit"><div class="property-title">[^<]*</div>\s*(\d+)'
166
+ )
167
+ _INTERACTION_RE = re.compile(
168
+ r'class="section-title">\s*(?:Interaction|Протокол взаимодействия)\s*<'
169
+ )
170
+ _SAMPLE_LINE_RE = re.compile(
171
+ r'<div class="[^"]*\btest-example-line-(\d+)\b[^"]*"[^>]*>([\s\S]*?)</div>'
172
+ )
173
+
174
+
175
+ _ANY_CASE_RE = re.compile(
176
+ r"any letter case|case[- ]insensitive|в любом регистре|in any (?:letter )?case(?!\s*,)",
177
+ re.IGNORECASE,
178
+ )
179
+ # "in any case" means letter case only after words about printing ("In any
180
+ # case, ..." is the other "in any case")
181
+ _PRINTING_RE = re.compile(r"print|output|letter|answer|string|word|yes|no\b", re.IGNORECASE)
182
+ _ERROR_RE = re.compile(
183
+ r"(?:(?:absolute|relative)\s+(?:or\s+(?:absolute|relative)\s+)?(?:error|precision)|погрешност)"
184
+ r"[^.]{0,160}?(?:10\s*(?:\^\s*\{?|\s)\s*[-−]\s*(\d+)|\b1\s*[eE]\s*[-−]\s*(\d+))",
185
+ re.IGNORECASE,
186
+ )
187
+ # "If there are several answers, print any of them": the checker takes any right
188
+ # answer, the sample's being only one (not "don't print anything", "print any letter
189
+ # in any case")
190
+ _ANY_ANSWER_RE = re.compile(
191
+ r"(?<!not )(?<!n't )(?<!never )\b(?:print|output)\s+any\b(?!\s+(?:letter|case|extra))"
192
+ r"|\bany\s+(?:one\s+)?of\s+them\b[^.]{0,30}\baccepted\b"
193
+ r"|\bother\s+(?:correct\s+|valid\s+)?answers\s+(?:are|may\s+be)\s+(?:also\s+)?"
194
+ r"(?:possible|accepted|correct)"
195
+ r"|выведите\s+любо|любой\s+из\s+них",
196
+ re.IGNORECASE,
197
+ )
198
+
199
+
200
+ @dataclass
201
+ class ProblemHeader:
202
+ name: str # "A. Watermelon"
203
+ time_limit: float | None # seconds
204
+ memory_limit: int | None # megabytes
205
+ interactive: bool
206
+ ignore_case: bool = False # the statement accepts answers in any letter case
207
+ epsilon: float | None = None # the absolute or relative error it accepts
208
+ any_answer: bool = False # it accepts any right answer, the sample's being one
209
+
210
+
211
+ def _text(fragment: str) -> str:
212
+ return re.sub(r"\s+", " ", html_lib.unescape(_TAG_RE.sub(" ", fragment)))
213
+
214
+
215
+ def _statement_text(body: str) -> str:
216
+ """The statement's text before the samples, tags removed."""
217
+ start = body.find('class="problem-statement"')
218
+ if start < 0:
219
+ return ""
220
+ stop = body.find('class="sample-tests"', start)
221
+ return _text(body[start : stop if stop >= 0 else len(body)])
222
+
223
+
224
+ def _note_text(body: str) -> str:
225
+ """The text of the note after the samples ("other answers are possible")."""
226
+ start = body.find('class="note"')
227
+ return _text(body[start:]) if start >= 0 else ""
228
+
229
+
230
+ def _accepts_any_case(statement: str) -> bool:
231
+ """Whether the statement accepts answers in any letter case."""
232
+ for match in _ANY_CASE_RE.finditer(statement):
233
+ if not match.group().lower().startswith("in any"):
234
+ return True
235
+ if _PRINTING_RE.search(statement[max(0, match.start() - 80) : match.start()]):
236
+ return True
237
+ return False
238
+
239
+
240
+ def find_problem_header(body: str) -> ProblemHeader:
241
+ """A problem page's title, limits, whether it is interactive, and how
242
+ lenient its checker is by the statement (a limit the page doesn't show,
243
+ as on acmsguru, is None)."""
244
+ title = _TITLE_RE.search(body)
245
+ time_limit = _TIME_LIMIT_RE.search(body)
246
+ memory_limit = _MEMORY_LIMIT_RE.search(body)
247
+ statement = _statement_text(body)
248
+ error = _ERROR_RE.search(statement)
249
+ return ProblemHeader(
250
+ name=html_lib.unescape(_TAG_RE.sub("", title.group(1))).strip() if title else "",
251
+ time_limit=float(time_limit.group(1)) if time_limit else None,
252
+ memory_limit=int(memory_limit.group(1)) if memory_limit else None,
253
+ interactive=bool(_INTERACTION_RE.search(body)),
254
+ ignore_case=_accepts_any_case(statement),
255
+ epsilon=10.0 ** -int(error.group(1) or error.group(2)) if error else None,
256
+ any_answer=bool(_ANY_ANSWER_RE.search(f"{statement} {_note_text(body)}")),
257
+ )
258
+
259
+
260
+ # ------------------------------------------------- the statement as Markdown
261
+
262
+
263
+ def statement_markdown(body: str) -> str:
264
+ """A problem's statement as Markdown; "" when the page has none. Its parts
265
+ in order, as text without inline formatting:
266
+ - the title and the limits
267
+ - each section (legend, Input, Output, Note, ...) under its heading
268
+ - the examples as code blocks"""
269
+ statement = BeautifulSoup(body, "lxml").find("div", class_="problem-statement")
270
+ if statement is None:
271
+ return ""
272
+ for tag in statement.select("img, sub, sup, br"):
273
+ if tag.name == "img":
274
+ tag.replace_with(f"![]({_absolute(tag.get('src', ''))})")
275
+ else: # NOTE: old statements write a<sub>i</sub>, 10<sup>5</sup>: a_i, 10^5
276
+ mark = {"sub": "_", "sup": "^"}.get(tag.name)
277
+ tag.replace_with(mark + "".join(tag.get_text().split()) if mark else "\n")
278
+ parts = []
279
+ for div in statement.find_all("div", recursive=False):
280
+ classes = div.get("class") or []
281
+ title = div.find("div", class_=["title", "section-title"])
282
+ if title is not None:
283
+ parts.append(("# " if "header" in classes else "## ") + _words(title))
284
+ title.extract()
285
+ if "header" in classes:
286
+ parts.append(
287
+ " · ".join(
288
+ d.get_text(": ", strip=True) for d in div.find_all("div", recursive=False)
289
+ )
290
+ )
291
+ elif "sample-tests" in classes:
292
+ parts += _samples_markdown(div)
293
+ else:
294
+ parts += _blocks(div)
295
+ text = "\n\n".join(part for part in parts if part.strip())
296
+ # NOTE: Codeforces writes TeX between $$$ (inline) and $$$$$$ (display)
297
+ return text.replace("$$$$$$", "$$").replace("$$$", "$") + "\n"
298
+
299
+
300
+ def _words(tag) -> str:
301
+ return " ".join(tag.get_text().split())
302
+
303
+
304
+ def _blocks(node) -> list[str]:
305
+ """A section's paragraphs, list items (- ...) and preformatted text (code
306
+ blocks), in order; loose text between them is a paragraph too."""
307
+ blocks, loose = [], []
308
+ for child in node.children:
309
+ name = getattr(child, "name", None)
310
+ if name not in ("p", "div", "center", "ul", "ol", "pre"):
311
+ loose.append("" if isinstance(child, Comment) else child.get_text())
312
+ continue
313
+ blocks.append(" ".join("".join(loose).split()))
314
+ loose.clear()
315
+ if name == "pre":
316
+ blocks.append(_fenced(child.get_text()))
317
+ elif name in ("ul", "ol"):
318
+ blocks += ["- " + _words(item) for item in child.find_all("li", recursive=False)]
319
+ else:
320
+ blocks += (
321
+ _blocks(child) if child.find(["p", "div", "ul", "ol", "pre"]) else [_words(child)]
322
+ )
323
+ blocks.append(" ".join("".join(loose).split()))
324
+ return [block for block in blocks if block]
325
+
326
+
327
+ def _samples_markdown(div) -> list[str]:
328
+ """The examples, each input and output in a code block."""
329
+ try:
330
+ inputs, outputs = find_samples(str(div))
331
+ except CFError:
332
+ return []
333
+ parts = []
334
+ for i, (sample_in, sample_out) in enumerate(zip(inputs, outputs), start=1):
335
+ number = f" {i}" if len(inputs) > 1 else ""
336
+ parts += [
337
+ f"### Input{number}",
338
+ _fenced(sample_in),
339
+ f"### Output{number}",
340
+ _fenced(sample_out),
341
+ ]
342
+ return parts
343
+
344
+
345
+ def _fenced(text: str) -> str:
346
+ return f"```\n{text.strip(chr(10))}\n```"
347
+
348
+
349
+ def _absolute(url: str) -> str:
350
+ if url.startswith("//"):
351
+ return "https:" + url
352
+ return "https://codeforces.com" + url if url.startswith("/") else url
353
+
354
+
355
+ def _line_cases(inner: str) -> list[int] | None:
356
+ """The test case of each line of a sample <pre>, as its text keeps them;
357
+ None for the classic markup, which doesn't say."""
358
+ if "test-example-line" not in inner:
359
+ return None
360
+ lines = [
361
+ (int(case), html_lib.unescape(_TAG_RE.sub("", text)))
362
+ for case, text in _SAMPLE_LINE_RE.findall(inner)
363
+ ]
364
+ # NOTE: _pre_to_text strips the whole text, which drops blank first and last lines
365
+ while lines and not lines[0][1].strip():
366
+ lines.pop(0)
367
+ while lines and not lines[-1][1].strip():
368
+ lines.pop()
369
+ return [case for case, _ in lines]
370
+
371
+
372
+ def find_sample_cases(body: str) -> list[tuple[list[int], list[int]] | None]:
373
+ """For each sample, the test case of each input and answer line (case 0:
374
+ the line with the number of cases); None where the page doesn't say."""
375
+ found = []
376
+ for inner_in, inner_out in zip(_INPUT_RE.findall(body), _OUTPUT_RE.findall(body)):
377
+ input_cases, answer_cases = _line_cases(inner_in), _line_cases(inner_out)
378
+ found.append(
379
+ None if input_cases is None or answer_cases is None else (input_cases, answer_cases)
380
+ )
381
+ return found
382
+
383
+
384
+ _HOLDER_RE = re.compile(r'<div class="problemindexholder" problemindex="(\w+)"')
385
+
386
+
387
+ def split_problems(body: str) -> list[tuple[str, str]]:
388
+ """A contest's all-problems page (/problems) as (index, that problem's
389
+ part of the page), in order; [] when it isn't one."""
390
+ starts = list(_HOLDER_RE.finditer(body))
391
+ return [
392
+ (
393
+ match.group(1),
394
+ body[match.start() : (starts[i + 1].start() if i + 1 < len(starts) else len(body))],
395
+ )
396
+ for i, match in enumerate(starts)
397
+ ]
398
+
399
+
400
+ def is_standard_io(body: str) -> bool:
401
+ return bool(_STANDARD_INPUT_RE.search(body) and _STANDARD_OUTPUT_RE.search(body))
402
+
403
+
404
+ def find_countdown(body: str) -> int:
405
+ match = _COUNTDOWN_RE.search(body)
406
+ if not match:
407
+ raise CFError("Cannot find any countdown")
408
+ h, m, s = (int(g) for g in match.groups())
409
+ return h * 3600 + m * 60 + s
410
+
411
+
412
+ def find_error_message(body: str) -> str | None:
413
+ """First non-empty form error (forms also carry empty error placeholders)."""
414
+ for match in _ERROR_MSG_RE.finditer(body):
415
+ message = html_lib.unescape(_TAG_RE.sub("", match.group(1))).strip()
416
+ if message:
417
+ return message
418
+ return None
419
+
420
+
421
+ def find_message(body: str) -> str | None:
422
+ """The Codeforces.showMessage flash text, if present."""
423
+ match = _SHOW_MSG_RE.search(body)
424
+ return match.group(1) if match else None
425
+
426
+
427
+ def find_code(body: str) -> str:
428
+ match = _CODE_RE.search(body)
429
+ if not match:
430
+ raise CFError("Cannot find any code")
431
+ return html_lib.unescape(match.group(1))
432
+
433
+
434
+ def find_cf_offset(body: str) -> str:
435
+ match = _UTC_OFFSET_RE.search(body)
436
+ if not match:
437
+ raise CFError("Cannot find cf utc offset")
438
+ return match.group(1)
439
+
440
+
441
+ # ------------------------------------------------------------- problem list
442
+
443
+
444
+ @dataclass
445
+ class ProblemRow:
446
+ id: str
447
+ name: str
448
+ io: str
449
+ limit: str
450
+ passed: str
451
+ state: str # tr class, e.g. "accepted-problem" / "rejected-problem" / ""
452
+
453
+
454
+ def parse_problems(body: str) -> list[ProblemRow]:
455
+ soup = BeautifulSoup(body, "lxml")
456
+ table = soup.find("table", class_="problems")
457
+ if table is None:
458
+ raise CFError("No problem list on the page: has the contest begun?")
459
+ problems = []
460
+ for row in table.find_all("tr")[1:]:
461
+ cells = row.find_all("td")
462
+ if len(cells) < 2:
463
+ continue
464
+ problem_id = cells[0].get_text(strip=True)
465
+ name_link = cells[1].find("a")
466
+ name = name_link.get_text(strip=True) if name_link else ""
467
+ notice = cells[1].find("div", class_="notice")
468
+ io_text, limit = "", ""
469
+ if notice:
470
+ parts = [p.strip() for p in notice.get_text("\n").splitlines() if p.strip()]
471
+ if parts:
472
+ io_text = parts[0]
473
+ if len(parts) > 1:
474
+ limit = ", ".join(parts[1:])
475
+ passed = "0"
476
+ if len(cells) > 3:
477
+ digits = _NUM_RE.search(cells[3].get_text())
478
+ if digits:
479
+ passed = digits.group(0)
480
+ problems.append(
481
+ ProblemRow(
482
+ id=problem_id,
483
+ name=name,
484
+ io=io_text,
485
+ limit=limit,
486
+ passed=passed,
487
+ state=" ".join(row.get("class", [])),
488
+ )
489
+ )
490
+ if not problems:
491
+ raise CFError("No problems on the page: has the contest begun?")
492
+ return problems
493
+
494
+
495
+ # --------------------------------------------------------------- languages
496
+
497
+
498
+ def parse_languages(body: str) -> dict[str, str]:
499
+ """id -> display name from the submit page's programTypeId <select>."""
500
+ soup = BeautifulSoup(body, "lxml")
501
+ select = soup.find("select", attrs={"name": "programTypeId"})
502
+ if select is None:
503
+ return {}
504
+ return {
505
+ opt["value"]: opt.get_text(strip=True)
506
+ for opt in select.find_all("option")
507
+ if opt.get("value")
508
+ }
509
+
510
+
511
+ # -------------------------------------------------------------- submissions
512
+
513
+ _WAIT_VERDICTS = ("null", "TESTING", "SUBMITTED")
514
+ _VERDICT_FORMAT_RE = re.compile(r"<span\sclass=[\"']?verdict-format-[^\"'>]+[\"']?>")
515
+ _VERDICT_COLOR_RE = re.compile(r"<span\sclass=[\"']?verdict-([^\"'>]+?)[\"']?>")
516
+
517
+ # ${c-...} marker -> rich style
518
+ STATUS_STYLE = {
519
+ "${c-waiting}": "white",
520
+ "${c-failed}": "red",
521
+ "${c-accepted}": "green",
522
+ "${c-rejected}": "blue",
523
+ }
524
+
525
+
526
+ @dataclass
527
+ class Submission:
528
+ id: int = 0
529
+ name: str = ""
530
+ lang: str = ""
531
+ status: str = "" # with ${c-...}/${f-...} markers for its color and numbers
532
+ time_ms: int = 0
533
+ memory_bytes: int = 0
534
+ when: str = ""
535
+ passed: int = 0
536
+ end: bool = False
537
+
538
+ def styled_status(self) -> tuple[str, str]:
539
+ """(plain text, rich style) for display."""
540
+ status = self.status
541
+ for marker in ("${f-points}", "${f-passed}", "${f-judged}"):
542
+ status = status.replace(marker, str(self.passed))
543
+ style = ""
544
+ for marker, marker_style in STATUS_STYLE.items():
545
+ if marker in status:
546
+ status = status.replace(marker, "")
547
+ style = marker_style
548
+ return status, style
549
+
550
+ def parse_memory(self) -> str:
551
+ if self.memory_bytes > 1024 * 1024:
552
+ return f"{self.memory_bytes / 1024 / 1024:.2f} MB"
553
+ if self.memory_bytes > 1024:
554
+ return f"{self.memory_bytes / 1024:.2f} KB"
555
+ return f"{self.memory_bytes} B"
556
+
557
+ def parse_time(self) -> str:
558
+ return f"{self.time_ms} ms"
559
+
560
+ def problem_index(self) -> str:
561
+ head, _, _ = self.name.partition(" ")
562
+ return head.lower().rstrip("-")
563
+
564
+
565
+ def _parse_when(raw: str, cf_offset: str) -> str:
566
+ for fmt in ("%d.%m.%Y %H:%M", "%b/%d/%Y %H:%M"):
567
+ try:
568
+ naive = datetime.strptime(raw, fmt)
569
+ break
570
+ except ValueError:
571
+ continue
572
+ else:
573
+ return raw
574
+ match = re.match(r"([+-])(\d{2}):(\d{2})", cf_offset)
575
+ if not match:
576
+ return naive.strftime("%Y-%m-%d %H:%M")
577
+ sign = 1 if match.group(1) == "+" else -1
578
+ offset = timezone(sign * timedelta(hours=int(match.group(2)), minutes=int(match.group(3))))
579
+ return naive.replace(tzinfo=offset).astimezone().strftime("%Y-%m-%d %H:%M")
580
+
581
+
582
+ def _parse_submission_row(row, cf_offset: str) -> Submission:
583
+ def text_of(node) -> str:
584
+ return node.get_text(strip=True) if node else ""
585
+
586
+ def int_of(node) -> int:
587
+ digits = _NUM_RE.search(node.get_text()) if node else None
588
+ return int(digits.group(0)) if digits else 0
589
+
590
+ verdict_wrapper = row.find(class_="submissionVerdictWrapper")
591
+ end = False
592
+ status = "Unknown"
593
+ if verdict_wrapper:
594
+ verdict = verdict_wrapper.get("submissionverdict", "null")
595
+ end = verdict not in _WAIT_VERDICTS
596
+ inner = verdict_wrapper.decode_contents()
597
+ inner = _VERDICT_FORMAT_RE.sub("", inner)
598
+ inner = _VERDICT_COLOR_RE.sub(r"${c-\1}", inner)
599
+ inner = _TAG_RE.sub("", inner)
600
+ status = html_lib.unescape(inner).strip() or "Unknown"
601
+
602
+ when_node = row.find(class_="format-time")
603
+ if when_node:
604
+ when = _parse_when(text_of(when_node), cf_offset)
605
+ else:
606
+ cells = row.find_all("td")
607
+ when = text_of(cells[1]) if len(cells) > 1 else ""
608
+
609
+ digits = _NUM_RE.search(status)
610
+ passed = int(digits.group(0)) if digits else 0
611
+
612
+ lang_cell = row.find(lambda tag: tag.name == "td" and not tag.has_attr("class"))
613
+ return Submission(
614
+ id=int_of(row.find(class_="id-cell")),
615
+ name=text_of(row.find("td", attrs={"data-problemid": True})),
616
+ lang=text_of(lang_cell),
617
+ status=status,
618
+ time_ms=int_of(row.find(class_="time-consumed-cell")),
619
+ memory_bytes=int_of(row.find(class_="memory-consumed-cell")) * 1024,
620
+ when=when,
621
+ passed=passed,
622
+ end=end,
623
+ )
624
+
625
+
626
+ def parse_submissions(body: str, count: int = -1) -> list[Submission]:
627
+ """Submissions from a /my (or status) page, newest first."""
628
+ cf_offset = ""
629
+ try:
630
+ cf_offset = find_cf_offset(body)
631
+ except CFError:
632
+ pass
633
+ soup = BeautifulSoup(body, "lxml")
634
+ rows = soup.find_all("tr", attrs={"data-submission-id": True})
635
+ if count >= 0:
636
+ rows = rows[:count]
637
+ submissions = [_parse_submission_row(row, cf_offset) for row in rows]
638
+ if not submissions:
639
+ raise CFError("Cannot find any submission")
640
+ return submissions
641
+
642
+
643
+ # ------------------------------------------------- a submission's tests
644
+
645
+
646
+ @dataclass
647
+ class TestDetail:
648
+ """A test of a judged submission, as Codeforces shows it under "Click to
649
+ see test details" (a long part cut to its start and "...")."""
650
+
651
+ __test__ = False # NOTE: not a pytest test class
652
+
653
+ number: int
654
+ verdict: str # WRONG_ANSWER, TIME_LIMIT_EXCEEDED, ...
655
+ input: str
656
+ output: str
657
+ answer: str
658
+ checker: str # what the checker said
659
+
660
+ @property
661
+ def cut(self) -> bool:
662
+ """Whether Codeforces cut the input or the answer short."""
663
+ return self.input.endswith("...") or self.answer.endswith("...")
664
+
665
+
666
+ @dataclass
667
+ class SubmissionDetails:
668
+ verdict: str # "Wrong answer on test 27"
669
+ problem: str # "(F) Tea Blend"
670
+ failed: TestDetail | None # the test it failed, if any
671
+
672
+
673
+ def has_test_details(body: str) -> bool:
674
+ """Whether a submission page offers its tests' details: only your own
675
+ submissions do, once the contest is over."""
676
+ return "click-to-view-tests" in body
677
+
678
+
679
+ def parse_submission_details(data: dict) -> SubmissionDetails:
680
+ """A submission's verdict and failed test from /data/submitSource's JSON
681
+ (per test N: input#N, output#N, answer#N, rejected#N, ...)."""
682
+
683
+ def text(key: str) -> str:
684
+ return str(data.get(key) or "").replace("\r\n", "\n")
685
+
686
+ failed = None
687
+ count = int(data["testCount"]) if str(data.get("testCount", "")).isdigit() else 0
688
+ for n in range(1, count + 1):
689
+ if text(f"rejected#{n}").lower() == "true":
690
+ failed = TestDetail(
691
+ n,
692
+ text(f"verdict#{n}"),
693
+ text(f"input#{n}"),
694
+ text(f"output#{n}"),
695
+ text(f"answer#{n}"),
696
+ text(f"checkerStdoutAndStderr#{n}").strip(),
697
+ )
698
+ break
699
+ verdict = " ".join(html_lib.unescape(_TAG_RE.sub("", text("verdict"))).split())
700
+ problem = " ".join(html_lib.unescape(_TAG_RE.sub("", text("problemName"))).split())
701
+ return SubmissionDetails(verdict, problem, failed)
702
+
703
+
704
+ # ------------------------------------------------------------- contests (API)
705
+
706
+
707
+ @dataclass(frozen=True)
708
+ class Contest:
709
+ id: int
710
+ name: str
711
+ start: int # seconds since the epoch
712
+ duration: int # seconds
713
+ phase: str # "BEFORE", "CODING", "FINISHED", ...
714
+
715
+
716
+ def parse_contests(result: list) -> list[Contest]:
717
+ """The API's contest.list result as Contests (those without a start time left out)."""
718
+ return [
719
+ Contest(
720
+ int(c["id"]),
721
+ str(c.get("name", "")),
722
+ int(c["startTimeSeconds"]),
723
+ int(c.get("durationSeconds", 0)),
724
+ str(c.get("phase", "")),
725
+ )
726
+ for c in result
727
+ if isinstance(c, dict) and "id" in c and "startTimeSeconds" in c
728
+ ]
729
+
730
+
731
+ @dataclass(frozen=True)
732
+ class ProblemsetProblem:
733
+ contest_id: int
734
+ index: str # "B", "F1"
735
+ name: str
736
+ rating: int | None # None: not rated yet
737
+ tags: tuple[str, ...]
738
+
739
+ @property
740
+ def id(self) -> str:
741
+ return f"{self.contest_id}{self.index}"
742
+
743
+
744
+ def parse_problemset(result: dict) -> list[ProblemsetProblem]:
745
+ """The API's problemset.problems result as problems."""
746
+ return [
747
+ ProblemsetProblem(
748
+ int(p["contestId"]),
749
+ str(p["index"]),
750
+ str(p.get("name", "")),
751
+ int(p["rating"]) if isinstance(p.get("rating"), (int, float)) else None,
752
+ tuple(str(tag) for tag in p.get("tags", [])),
753
+ )
754
+ for p in result.get("problems", [])
755
+ if isinstance(p, dict) and "contestId" in p and "index" in p
756
+ ]
757
+
758
+
759
+ def parse_solved(result: list) -> set[tuple[int, str]]:
760
+ """(contest id, index) of every problem an API user.status result shows accepted."""
761
+ return {
762
+ (int(sub["problem"]["contestId"]), str(sub["problem"]["index"]))
763
+ for sub in result
764
+ if isinstance(sub, dict)
765
+ and sub.get("verdict") == "OK"
766
+ and "contestId" in sub.get("problem", {})
767
+ }