diff --git a/AGENTS.md b/AGENTS.md index 1646598..5e6a327 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,8 @@ When removing a provider, remove or adjust the same surfaces so stale platform l ## Validation +Do not proactively create a Python virtual environment unless the user asks for one. Run Python commands directly with `python`. + After changing fetching, filtering, output shape, provider support, or workflow-generated files, run: ```bash diff --git a/README.md b/README.md index fd266d4..a6a55db 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Fetch programming contest schedules from several online judge platforms and expo - Codeforces - AtCoder - USACO -- Luogu +- UOJ ## Output Files @@ -93,6 +93,6 @@ AI coding agents and maintainers should follow [AGENTS.md](AGENTS.md) when chang ## Notes - AtCoder uses the AtCoder Problems API and falls back to the official AtCoder contest page for upcoming contests when needed. -- Luogu requires a session request and content-only request headers, so the script uses `requests.Session()`. +- UOJ is parsed from its public contests page, including the timeanddate duration parameters used by contest links. - USACO schedule data depends on the current layout of the official USACO contests page. If USACO has no future schedule published, it may produce no upcoming USACO contests. - Network or upstream API changes can temporarily reduce the number of fetched contests. diff --git a/README_zh.md b/README_zh.md index 9ce55c9..ed9f6f8 100644 --- a/README_zh.md +++ b/README_zh.md @@ -9,7 +9,7 @@ - Codeforces - AtCoder - USACO -- 洛谷 +- UOJ ## 输出文件 @@ -93,6 +93,6 @@ AI coding agents 和维护者在修改比赛提供商、输出契约、工作流 ## 注意事项 - AtCoder 使用 AtCoder Problems API;在需要时会回退解析 AtCoder 官方比赛页面以获取即将开始的比赛。 -- 洛谷需要先建立会话,并带上 content-only 请求头,因此脚本使用了 `requests.Session()`。 +- UOJ 从公开比赛列表页面解析,并读取比赛链接中的 timeanddate 持续时间参数。 - USACO 赛程依赖 USACO 官网当前页面结构。如果 USACO 暂未发布未来赛程,可能不会产生即将开始的 USACO 比赛。 - 网络问题或上游 API/页面结构变化,可能会暂时影响抓取到的比赛数量。 diff --git a/contests_all.json b/contests_all.json index 86dae27..6ab4044 100644 --- a/contests_all.json +++ b/contests_all.json @@ -559,6 +559,14 @@ "status": "finished", "url": "https://atcoder.jp/contests/awc0107" }, + { + "platform": "UOJ", + "title": "UOJ Long Round #4", + "start_time": 1783216800, + "end_time": 1783432800, + "status": "finished", + "url": "https://uoj.ac/contest/108" + }, { "platform": "AtCoder", "title": "AtCoder Daily Training ALL 2026/07/08 16:00start", @@ -695,6 +703,14 @@ "status": "finished", "url": "https://atcoder.jp/contests/adt_easy_20260710_2" }, + { + "platform": "UOJ", + "title": "UOJ NOI Round #10 笔试", + "start_time": 1783681200, + "end_time": 1783683000, + "status": "finished", + "url": "https://uoj.ac/contest/109" + }, { "platform": "AtCoder", "title": "AtCoder Weekday Contest 0110 Beta", @@ -703,6 +719,14 @@ "status": "finished", "url": "https://atcoder.jp/contests/awc0110" }, + { + "platform": "UOJ", + "title": "UOJ NOI Round #10 Day1", + "start_time": 1783729800, + "end_time": 1783748400, + "status": "finished", + "url": "https://uoj.ac/contest/110" + }, { "platform": "AtCoder", "title": "AtCoder Beginner Contest 466", @@ -711,6 +735,14 @@ "status": "finished", "url": "https://atcoder.jp/contests/abc466" }, + { + "platform": "UOJ", + "title": "UOJ NOI Round #10 Day2", + "start_time": 1783816200, + "end_time": 1783834200, + "status": "finished", + "url": "https://uoj.ac/contest/111" + }, { "platform": "AtCoder", "title": "AtCoder Regular Contest-- 224", diff --git a/fetch_contests.py b/fetch_contests.py index 38aa53b..7e10530 100644 --- a/fetch_contests.py +++ b/fetch_contests.py @@ -3,6 +3,7 @@ import json import time import re from datetime import datetime, timezone +from urllib.parse import urljoin, urlparse, parse_qs from bs4 import BeautifulSoup # ---------- 工具函数 ---------- @@ -63,6 +64,24 @@ MONTH_MAP = {"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, 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 + def infer_usaco_year(season, month_num): start_year, end_year = map(int, season.split("-")) return start_year if month_num >= 8 else end_year @@ -201,55 +220,39 @@ def fetch_usaco(include_all=False): print(f"USACO error: {e}") return [] -# ---------- 洛谷 ---------- -def fetch_luogu(include_all=False, min_end_time=None): - url = "https://www.luogu.com.cn/contest/list" - headers = { - **HEADERS, - "Accept": "application/json, text/plain, */*", - "Referer": "https://www.luogu.com.cn/contest/list", - "X-Requested-With": "XMLHttpRequest", - "x-lentille-request": "content-only" - } +# ---------- UOJ ---------- +def fetch_uoj(include_all=False, min_end_time=None): + url = "https://uoj.ac/contests" try: - session = requests.Session() - session.headers.update(headers) - session.get("https://www.luogu.com.cn/contest/list", timeout=15) + resp = requests.get(url, headers=HEADERS, timeout=15) + resp.raise_for_status() + soup = BeautifulSoup(resp.text, "html.parser") contests = [] now = now_ts() - page = 1 - total = None - while True: - resp = session.get(url, params={"page": page, "_contentOnly": 1}, timeout=15) - resp.raise_for_status() - data = resp.json() - if data.get("status") != 200 and data.get("code") != 200: - return contests - contest_data = data.get("data") or data.get("currentData") or {} - page_data = contest_data.get("contests", {}) - result = page_data.get("result", []) - if total is None: - total = page_data.get("count", len(result)) - if not result: - break - for c in result: - # endTime 是秒级时间戳;默认只保留尚未结束的比赛,全量模式保留全部 - if (include_all and (min_end_time is None or c["endTime"] >= min_end_time)) or c["endTime"] > now: - contests.append(make_contest( - "洛谷", - c["name"], - c["startTime"], - c["endTime"], - f"https://www.luogu.com.cn/contest/{c['id']}" - )) - if include_all and min_end_time is not None and max(c["endTime"] for c in result) < min_end_time: - break - if not include_all or page * page_data.get("perPage", len(result)) >= total: - break - page += 1 + 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"Luogu error: {e}") + print(f"UOJ error: {e}") return [] # ---------- 主逻辑 ---------- @@ -260,7 +263,7 @@ def main(): all_contests.extend(fetch_codeforces()) all_contests.extend(fetch_atcoder()) all_contests.extend(fetch_usaco()) - all_contests.extend(fetch_luogu()) + all_contests.extend(fetch_uoj()) all_contests.sort(key=lambda x: x["start_time"]) @@ -273,7 +276,7 @@ def main(): recent_finished_contests.extend(fetch_codeforces(include_all=True)) recent_finished_contests.extend(fetch_atcoder(include_all=True)) recent_finished_contests.extend(fetch_usaco(include_all=True)) - recent_finished_contests.extend(fetch_luogu(include_all=True, min_end_time=recent_finished_min_end_time)) + recent_finished_contests.extend(fetch_uoj(include_all=True, min_end_time=recent_finished_min_end_time)) recent_finished_contests = filter_recent_finished(deduplicate_contests(recent_finished_contests)) recent_finished_contests.sort(key=lambda x: x["end_time"]) diff --git a/server/index.php b/server/index.php index 776a954..d98a18a 100644 --- a/server/index.php +++ b/server/index.php @@ -67,7 +67,7 @@ $platform_class = [ 'Codeforces' => 'cf', 'AtCoder' => 'atc', 'USACO' => 'usaco', - '洛谷' => 'luogu' + 'UOJ' => 'uoj' ]; // 辅助函数:Unix 时间戳转中文日期 @@ -97,7 +97,7 @@ function format_time($ts) { .platform.cf { background: #1f8acb; } .platform.atc { background: #5b8c5a; } .platform.usaco { background: #e67e22; } - .platform.luogu { background: #e74c3c; } + .platform.uoj { background: #c0392b; } .info { flex: 1; margin-left: 15px; } .title { font-size: 18px; font-weight: 600; color: #2c3e50; } .time { font-size: 14px; color: #7f8c8d; margin-top: 5px; }