|
3 | 3 | import json |
4 | 4 | import logging |
5 | 5 | import math |
| 6 | +import re |
6 | 7 | import statistics |
7 | 8 | from collections import defaultdict |
8 | 9 |
|
@@ -409,26 +410,46 @@ def get_current_models() -> dict[str, ProblemModel]: |
409 | 410 | return {} |
410 | 411 |
|
411 | 412 |
|
| 413 | +def _parse_rated_range(rate_change: str) -> tuple[int, int | None] | None: |
| 414 | + """Parse AtCoder's "rated range" label into (lower, upper) rating bounds. |
| 415 | +
|
| 416 | + AtCoder used to render the range with a "~" separator (e.g. " ~ 1999", |
| 417 | + "1200 ~ "), but switched to "-" (e.g. " - 1999", "1200 - ") in late 2025. |
| 418 | + Both separators are accepted here so contest classification keeps working |
| 419 | + across the format change. An open upper bound (rated for "X and above", |
| 420 | + e.g. "2000 -" or "All") is represented by ``None``. |
| 421 | +
|
| 422 | + Returns ``None`` for unrated contests ("-") or unrecognized labels. |
| 423 | + """ |
| 424 | + text = rate_change.strip() |
| 425 | + if text in ("", "-"): |
| 426 | + return None |
| 427 | + if text == "All": |
| 428 | + return (0, None) |
| 429 | + match = re.fullmatch(r"\s*(\d*)\s*[-~]\s*(\d*)\s*", text) |
| 430 | + if match is None: |
| 431 | + return None |
| 432 | + lower = int(match.group(1)) if match.group(1) else 0 |
| 433 | + upper = int(match.group(2)) if match.group(2) else None |
| 434 | + return (lower, upper) |
| 435 | + |
| 436 | + |
412 | 437 | def infer_contest_type(contest: Contest) -> ContestType: |
413 | | - if ( |
414 | | - contest.rate_change == "All" |
415 | | - or contest.rate_change == "1200 ~ " |
416 | | - or contest.rate_change == "2000 ~ " |
417 | | - ): |
418 | | - return ContestType.AGC |
419 | | - elif ( |
420 | | - contest.rate_change == " ~ 2799" |
421 | | - or contest.rate_change == "1200 ~ 2799" |
422 | | - or contest.rate_change == "1200 ~ 2399" |
423 | | - or contest.rate_change == "1600 ~ 2999" |
424 | | - ): |
425 | | - return ContestType.NEW_ARC |
426 | | - elif contest.rate_change == " ~ 1999": |
427 | | - return ContestType.NEW_ABC |
428 | | - elif contest.rate_change == " ~ 1199": |
429 | | - return ContestType.OLD_ABC |
430 | | - # rate_change == "-" |
431 | | - elif contest.id.startswith("arc"): |
| 438 | + rated_range = _parse_rated_range(contest.rate_change) |
| 439 | + if rated_range is not None: |
| 440 | + _lower, upper = rated_range |
| 441 | + if upper is None: |
| 442 | + # Rated for "X and above" (or "All") -> AGC |
| 443 | + return ContestType.AGC |
| 444 | + elif upper >= 2000: |
| 445 | + return ContestType.NEW_ARC |
| 446 | + elif upper >= 1200: |
| 447 | + return ContestType.NEW_ABC |
| 448 | + else: |
| 449 | + return ContestType.OLD_ABC |
| 450 | + # rate_change == "-" (unrated by AtCoder). Fall back to id-based rules |
| 451 | + # for contests held before the official rating system started. |
| 452 | + if contest.id.startswith("arc"): |
432 | 453 | return ContestType.OLD_UNRATED_ARC |
433 | 454 | elif contest.id.startswith("abc"): |
434 | 455 | return ContestType.OLD_UNRATED_ABC |
@@ -563,8 +584,12 @@ def main(): |
563 | 584 | target_contest_ids = args.target.split(",") if args.target else None |
564 | 585 | results = run(target_contest_ids=target_contest_ids, overwrite=args.overwrite) |
565 | 586 | ta = TypeAdapter(dict[str, ProblemModel]) |
| 587 | + # Omit fields that were not estimated (None) instead of serializing them as |
| 588 | + # `null`. The frontend treats a missing key as "not available" but rejects a |
| 589 | + # model that carries an explicit `null` (e.g. a problem with a difficulty but |
| 590 | + # no time model), which would otherwise hide its difficulty. |
566 | 591 | s3.Object("kenkoooo.com", "resources/problem-models.json").put( |
567 | | - Body=ta.dump_json(results), ContentType="application/json" |
| 592 | + Body=ta.dump_json(results, exclude_none=True), ContentType="application/json" |
568 | 593 | ) |
569 | 594 |
|
570 | 595 |
|
|
0 commit comments