import requests import json import time import re import html from datetime import datetime, timezone from urllib.parse import urljoin, urlparse, parse_qs from bs4 import BeautifulSoup # ---------- 工具函数 ---------- def now_ts(): return int(time.time()) def parse_duration_to_end(start_ts, seconds): return start_ts + seconds def get_status(start_ts, end_ts, current_ts=None): current_ts = current_ts or now_ts() if end_ts <= current_ts: return "finished" if start_ts > current_ts: return "upcoming" return "running" def make_contest(platform, title, start_time, end_time, url): return { "platform": platform, "title": title, "start_time": start_time, "end_time": end_time, "status": get_status(start_time, end_time), "url": url } def deduplicate_contests(contests): seen = set() unique = [] for contest in contests: key = (contest["platform"], contest["url"]) if key in seen: continue seen.add(key) unique.append(contest) return unique def recent_finished_cutoff(days=30): return now_ts() - days * 24 * 60 * 60 def filter_recent_finished(contests, days=30): current_ts = now_ts() earliest_end_ts = recent_finished_cutoff(days) return [ contest for contest in contests if contest["status"] == "finished" and contest["end_time"] >= earliest_end_ts ] def contest_payload(contests, generated_at): return { "generated_at": generated_at, "contests": contests } HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0 Safari/537.36" } def parse_atcoder_time(text): return int(datetime.strptime(text.strip(), "%Y-%m-%d %H:%M:%S%z").timestamp()) def parse_uoj_time(text): return int(datetime.strptime(text.strip(), "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc).timestamp()) - 8 * 3600 def parse_uoj_duration(time_url, duration_text): params = parse_qs(urlparse(time_url).query) seconds = 0 if params.get("ah"): seconds += int(float(params["ah"][0]) * 3600) if params.get("am"): seconds += int(float(params["am"][0]) * 60) if seconds: return seconds match = re.search(r"([\d.]+)\s*小时", duration_text) if match: return int(float(match.group(1)) * 3600) return 0 # ---------- Codeforces ---------- def fetch_codeforces(include_all=False): url = "https://codeforces.com/api/contest.list?gym=false" try: resp = requests.get(url, headers=HEADERS, timeout=15) data = resp.json() if data["status"] != "OK": return [] contests = [] now = now_ts() for c in data["result"]: start = c["startTimeSeconds"] end = start + c["durationSeconds"] if include_all or end > now: contests.append(make_contest( "Codeforces", c["name"], start, end, f"https://codeforces.com/contests/{c['id']}" )) return contests except Exception as e: print(f"Codeforces error: {e}") return [] # ---------- AtCoder (使用 kenkoooo 的非官方 API) ---------- def fetch_atcoder(include_all=False): url = "https://kenkoooo.com/atcoder/resources/contests.json" try: resp = requests.get(url, headers=HEADERS, timeout=15) resp.raise_for_status() data = resp.json() contests = [] now = now_ts() for c in data: start = c["start_epoch_second"] end = start + c["duration_second"] if start > 0 and (include_all or end > now): contests.append(make_contest( "AtCoder", c["title"], start, end, f"https://atcoder.jp/contests/{c['id']}" )) if contests and not include_all: return contests page = requests.get("https://atcoder.jp/contests/?lang=en", headers=HEADERS, timeout=15) page.raise_for_status() soup = BeautifulSoup(page.text, "html.parser") upcoming = soup.select_one("#contest-table-upcoming table tbody") if not upcoming: return [] for row in upcoming.select("tr"): cols = row.find_all("td") if len(cols) < 3: continue start_time = parse_atcoder_time(cols[0].get_text(strip=True)) duration_parts = [int(part) for part in cols[2].get_text(strip=True).split(":")] duration = duration_parts[0] * 3600 + duration_parts[1] * 60 link = cols[1].find("a", href=True) if (include_all or start_time > now) and link: contests.append(make_contest( "AtCoder", link.get_text(strip=True), start_time, start_time + duration, f"https://atcoder.jp{link['href']}" )) return deduplicate_contests(contests) except Exception as e: print(f"AtCoder error: {e}") return [] # ---------- UOJ ---------- def fetch_uoj(include_all=False, min_end_time=None): url = "https://uoj.ac/contests" try: resp = requests.get(url, headers=HEADERS, timeout=15) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") contests = [] now = now_ts() for row in soup.select("table tr"): cols = row.find_all("td") if len(cols) < 3: continue contest_link = cols[0].find("a", href=re.compile(r"^/contest/\d+$")) time_link = cols[1].find("a", href=True) if not contest_link or not time_link: continue start = parse_uoj_time(time_link.get_text(strip=True)) duration = parse_uoj_duration(time_link["href"], cols[2].get_text(" ", strip=True)) if duration <= 0: continue end = start + duration if (include_all and (min_end_time is None or end >= min_end_time)) or end > now: contests.append(make_contest( "UOJ", contest_link.get_text(strip=True), start, end, urljoin("https://uoj.ac", contest_link["href"]) )) return contests except Exception as e: print(f"UOJ error: {e}") return [] # ---------- Nowcoder ---------- def fetch_nowcoder(include_all=False, min_end_time=None): url = "https://ac.nowcoder.com/acm/contest/vip-index" try: resp = requests.get(url, headers=HEADERS, timeout=15) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") contests = [] now = now_ts() for item in soup.select(".platform-item[data-json]"): try: data = json.loads(html.unescape(item["data-json"])) except (TypeError, ValueError): continue contest_id = data.get("contestId") title = data.get("contestName") start = data.get("contestStartTime") end = data.get("contestEndTime") if not contest_id or not title or not start or not end: continue start = int(start) // 1000 end = int(end) // 1000 if (include_all and (min_end_time is None or end >= min_end_time)) or end > now: contests.append(make_contest( "Nowcoder", title, start, end, f"https://ac.nowcoder.com/acm/contest/{contest_id}" )) return contests except Exception as e: print(f"Nowcoder error: {e}") return [] def main(): generated_at = now_ts() all_contests = [] all_contests.extend(fetch_codeforces()) all_contests.extend(fetch_atcoder()) all_contests.extend(fetch_uoj()) all_contests.extend(fetch_nowcoder()) recent_finished_contests = [] recent_finished_min_end_time = recent_finished_cutoff() recent_finished_contests.extend(fetch_codeforces(include_all=True)) recent_finished_contests.extend(fetch_atcoder(include_all=True)) recent_finished_contests.extend(fetch_uoj(include_all=True, min_end_time=recent_finished_min_end_time)) recent_finished_contests.extend(fetch_nowcoder(include_all=True, min_end_time=recent_finished_min_end_time)) recent_finished_contests = filter_recent_finished(deduplicate_contests(recent_finished_contests)) all_contests = deduplicate_contests(all_contests + recent_finished_contests) all_contests.sort(key=lambda x: (x["start_time"], x["end_time"])) with open("contests_all.json", "w", encoding="utf-8") as f: json.dump(contest_payload(all_contests, generated_at), f, ensure_ascii=False, indent=2) print(f"Total active contests: {sum(1 for contest in all_contests if contest['status'] != 'finished')}") print(f"Total contests finished in last 30 days: {len(recent_finished_contests)}") if __name__ == "__main__": main()