cp-mcp 0.1.0__tar.gz

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,12 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .mypy_cache/
9
+ .venv/
10
+ venv/
11
+ .env
12
+ .DS_Store
cp_mcp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eesh Saxena
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
cp_mcp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.5
2
+ Name: cp-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing Codeforces and CodeChef data (ratings, submissions, problems, contests) as tools for any MCP client. No API keys required.
5
+ Project-URL: Homepage, https://github.com/eeshsaxena/cp-mcp
6
+ Project-URL: Repository, https://github.com/eeshsaxena/cp-mcp
7
+ Author: Eesh Saxena
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: codechef,codeforces,competitive-programming,llm,mcp,model-context-protocol,tools
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: httpx>=0.24
18
+ Requires-Dist: mcp<2,>=1.2
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7; extra == 'dev'
21
+ Requires-Dist: ruff>=0.5; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # cp-mcp
25
+
26
+ An [MCP](https://modelcontextprotocol.io) server that gives any LLM assistant live
27
+ access to **Codeforces**, **CodeChef**, and **LeetCode**: ratings, submissions,
28
+ solved-problem stats, practice-problem pickers, daily challenges, upcoming contests,
29
+ and a combined cross-site profile.
30
+
31
+ - **No API keys, accounts, or registration.** Codeforces comes from its free, open,
32
+ keyless public API; LeetCode from its public GraphQL endpoint; CodeChef is scraped
33
+ from public pages (it has no usable API). Nothing to sign up for, nothing to configure.
34
+ - **Model-agnostic.** It speaks MCP over stdio, so it works with any MCP client
35
+ (Claude Desktop, Cursor, Continue, Cline, LM Studio, ...) and therefore any model
36
+ behind them, including open-weight ones.
37
+
38
+ ## Tools
39
+
40
+ | Tool | What it does |
41
+ | --- | --- |
42
+ | `cf_user_info` | Codeforces rating / max rating / rank for one or more handles |
43
+ | `cf_rating_history` | Per-contest rank and rating delta on Codeforces |
44
+ | `cf_recent_submissions` | Recent submissions (problem, verdict, language) |
45
+ | `cf_solved_stats` | Distinct problems solved, counts by rating, top tags |
46
+ | `cf_search_problems` | Search the problemset by tag + rating range |
47
+ | `cf_recommend_problem` | Random practice problem in a rating range, optionally excluding ones a handle already solved |
48
+ | `cf_daily_problems` | A daily practice set of 1-4 problems, chosen deterministically from the date (same all day, refreshes at 00:00 UTC) |
49
+ | `cf_upcoming_contests` | Not-yet-started Codeforces contests, soonest first |
50
+ | `codechef_user_info` | CodeChef rating, stars, highest rating, global + country rank |
51
+ | `codechef_rating_history` | CodeChef rated-contest history |
52
+ | `codechef_upcoming_contests` | Upcoming CodeChef contests |
53
+ | `leetcode_user_info` | LeetCode ranking, reputation, solved by difficulty, contest rating |
54
+ | `leetcode_recent_submissions` | Recent accepted LeetCode submissions |
55
+ | `leetcode_daily_challenge` | Today's official LeetCode daily problem |
56
+ | `leetcode_upcoming_contests` | Upcoming LeetCode weekly/biweekly contests |
57
+ | `all_profiles` | One user across Codeforces + CodeChef + LeetCode in a single call |
58
+ | `daily_challenges` | Today's practice in one call: the Codeforces daily set + LeetCode's daily challenge |
59
+
60
+ ## Install and run
61
+
62
+ Requires Python 3.10+.
63
+
64
+ ```bash
65
+ # from a checkout
66
+ pip install -e .
67
+ cp-mcp # runs the stdio server (a client normally launches this for you)
68
+ ```
69
+
70
+ Or run without installing:
71
+
72
+ ```bash
73
+ python -m cp_mcp
74
+ ```
75
+
76
+ ## Wire it into a client
77
+
78
+ The server is launched by your MCP client over stdio. Point the client at either the
79
+ `cp-mcp` console script or `python -m cp_mcp`.
80
+
81
+ **Claude Desktop** — add to `claude_desktop_config.json`:
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "cp-mcp": {
87
+ "command": "cp-mcp"
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ If `cp-mcp` isn't on the client's PATH, use the interpreter form instead:
94
+
95
+ ```json
96
+ {
97
+ "mcpServers": {
98
+ "cp-mcp": {
99
+ "command": "python",
100
+ "args": ["-m", "cp_mcp"]
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
106
+ **Any other MCP client** (Cursor, Continue, Cline, LM Studio, an open-weight setup, ...):
107
+ register a stdio server with command `cp-mcp` (or `python -m cp_mcp`). No env vars, no keys.
108
+
109
+ ## Example asks
110
+
111
+ > "What's tourist's current Codeforces rating and their last 5 submissions?"
112
+ > "Give me an unsolved 1600-rated Codeforces DP problem for handle eeshsaxena."
113
+ > "What are today's daily practice problems?"
114
+ > "Show my CodeChef rating history and the next CodeChef contest."
115
+ > "What's today's LeetCode daily challenge?"
116
+ > "Give me all of today's challenges (Codeforces + LeetCode)."
117
+ > "Pull my profile on all three: Codeforces eeshsaxena, CodeChef eeshsaxena, LeetCode eeshsaxena."
118
+
119
+ ## Notes and limits
120
+
121
+ - **Codeforces** rate-limits the public API to roughly one request every two seconds;
122
+ a couple of the tools (`cf_solved_stats`, `cf_recommend_problem` with a handle) pull
123
+ a user's full submission list, so they take a moment for very active accounts.
124
+ - **CodeChef** has no public API, so those tools scrape public HTML. That is inherently
125
+ fragile: if CodeChef changes its markup or rate-limits the request, fields degrade to
126
+ `null` or the tool returns an `{"error": ...}` dict rather than crashing.
127
+ - **LeetCode** uses its public GraphQL endpoint (the same one the website uses). It is
128
+ keyless but undocumented, so a schema change on LeetCode's side could require updates.
129
+ - This project is not affiliated with Codeforces, CodeChef, or LeetCode. Use it within
130
+ their terms.
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ pip install -e ".[dev]"
136
+ pytest # network is fully mocked; tests run offline
137
+ ruff check .
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT. See [LICENSE](LICENSE).
cp_mcp-0.1.0/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # cp-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that gives any LLM assistant live
4
+ access to **Codeforces**, **CodeChef**, and **LeetCode**: ratings, submissions,
5
+ solved-problem stats, practice-problem pickers, daily challenges, upcoming contests,
6
+ and a combined cross-site profile.
7
+
8
+ - **No API keys, accounts, or registration.** Codeforces comes from its free, open,
9
+ keyless public API; LeetCode from its public GraphQL endpoint; CodeChef is scraped
10
+ from public pages (it has no usable API). Nothing to sign up for, nothing to configure.
11
+ - **Model-agnostic.** It speaks MCP over stdio, so it works with any MCP client
12
+ (Claude Desktop, Cursor, Continue, Cline, LM Studio, ...) and therefore any model
13
+ behind them, including open-weight ones.
14
+
15
+ ## Tools
16
+
17
+ | Tool | What it does |
18
+ | --- | --- |
19
+ | `cf_user_info` | Codeforces rating / max rating / rank for one or more handles |
20
+ | `cf_rating_history` | Per-contest rank and rating delta on Codeforces |
21
+ | `cf_recent_submissions` | Recent submissions (problem, verdict, language) |
22
+ | `cf_solved_stats` | Distinct problems solved, counts by rating, top tags |
23
+ | `cf_search_problems` | Search the problemset by tag + rating range |
24
+ | `cf_recommend_problem` | Random practice problem in a rating range, optionally excluding ones a handle already solved |
25
+ | `cf_daily_problems` | A daily practice set of 1-4 problems, chosen deterministically from the date (same all day, refreshes at 00:00 UTC) |
26
+ | `cf_upcoming_contests` | Not-yet-started Codeforces contests, soonest first |
27
+ | `codechef_user_info` | CodeChef rating, stars, highest rating, global + country rank |
28
+ | `codechef_rating_history` | CodeChef rated-contest history |
29
+ | `codechef_upcoming_contests` | Upcoming CodeChef contests |
30
+ | `leetcode_user_info` | LeetCode ranking, reputation, solved by difficulty, contest rating |
31
+ | `leetcode_recent_submissions` | Recent accepted LeetCode submissions |
32
+ | `leetcode_daily_challenge` | Today's official LeetCode daily problem |
33
+ | `leetcode_upcoming_contests` | Upcoming LeetCode weekly/biweekly contests |
34
+ | `all_profiles` | One user across Codeforces + CodeChef + LeetCode in a single call |
35
+ | `daily_challenges` | Today's practice in one call: the Codeforces daily set + LeetCode's daily challenge |
36
+
37
+ ## Install and run
38
+
39
+ Requires Python 3.10+.
40
+
41
+ ```bash
42
+ # from a checkout
43
+ pip install -e .
44
+ cp-mcp # runs the stdio server (a client normally launches this for you)
45
+ ```
46
+
47
+ Or run without installing:
48
+
49
+ ```bash
50
+ python -m cp_mcp
51
+ ```
52
+
53
+ ## Wire it into a client
54
+
55
+ The server is launched by your MCP client over stdio. Point the client at either the
56
+ `cp-mcp` console script or `python -m cp_mcp`.
57
+
58
+ **Claude Desktop** — add to `claude_desktop_config.json`:
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "cp-mcp": {
64
+ "command": "cp-mcp"
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ If `cp-mcp` isn't on the client's PATH, use the interpreter form instead:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "cp-mcp": {
76
+ "command": "python",
77
+ "args": ["-m", "cp_mcp"]
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ **Any other MCP client** (Cursor, Continue, Cline, LM Studio, an open-weight setup, ...):
84
+ register a stdio server with command `cp-mcp` (or `python -m cp_mcp`). No env vars, no keys.
85
+
86
+ ## Example asks
87
+
88
+ > "What's tourist's current Codeforces rating and their last 5 submissions?"
89
+ > "Give me an unsolved 1600-rated Codeforces DP problem for handle eeshsaxena."
90
+ > "What are today's daily practice problems?"
91
+ > "Show my CodeChef rating history and the next CodeChef contest."
92
+ > "What's today's LeetCode daily challenge?"
93
+ > "Give me all of today's challenges (Codeforces + LeetCode)."
94
+ > "Pull my profile on all three: Codeforces eeshsaxena, CodeChef eeshsaxena, LeetCode eeshsaxena."
95
+
96
+ ## Notes and limits
97
+
98
+ - **Codeforces** rate-limits the public API to roughly one request every two seconds;
99
+ a couple of the tools (`cf_solved_stats`, `cf_recommend_problem` with a handle) pull
100
+ a user's full submission list, so they take a moment for very active accounts.
101
+ - **CodeChef** has no public API, so those tools scrape public HTML. That is inherently
102
+ fragile: if CodeChef changes its markup or rate-limits the request, fields degrade to
103
+ `null` or the tool returns an `{"error": ...}` dict rather than crashing.
104
+ - **LeetCode** uses its public GraphQL endpoint (the same one the website uses). It is
105
+ keyless but undocumented, so a schema change on LeetCode's side could require updates.
106
+ - This project is not affiliated with Codeforces, CodeChef, or LeetCode. Use it within
107
+ their terms.
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ pip install -e ".[dev]"
113
+ pytest # network is fully mocked; tests run offline
114
+ ruff check .
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cp-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server exposing Codeforces and CodeChef data (ratings, submissions, problems, contests) as tools for any MCP client. No API keys required."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Eesh Saxena" }]
13
+ keywords = [
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "codeforces",
17
+ "codechef",
18
+ "competitive-programming",
19
+ "llm",
20
+ "tools",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 4 - Beta",
24
+ "Intended Audience :: Developers",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Programming Language :: Python :: 3",
27
+ "Topic :: Software Development :: Libraries",
28
+ ]
29
+ dependencies = [
30
+ # mcp 2.x is a breaking rewrite that removed mcp.server.fastmcp (FastMCP -> MCPServer).
31
+ # This server targets the stable 1.x FastMCP API; the SDK version does not affect
32
+ # which MCP clients or models can use the server.
33
+ "mcp>=1.2,<2",
34
+ "httpx>=0.24",
35
+ ]
36
+
37
+ [project.optional-dependencies]
38
+ dev = ["pytest>=7", "ruff>=0.5"]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/eeshsaxena/cp-mcp"
42
+ Repository = "https://github.com/eeshsaxena/cp-mcp"
43
+
44
+ [project.scripts]
45
+ cp-mcp = "cp_mcp.__main__:main"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/cp_mcp"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ include = ["src/cp_mcp", "README.md", "LICENSE", "tests"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py310"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "UP", "B"]
59
+ ignore = ["E501"]
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ """cp-mcp: MCP server for Codeforces and CodeChef data. No API keys required."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,12 @@
1
+ """Entry point: run the cp-mcp server over stdio."""
2
+ from __future__ import annotations
3
+
4
+ from .server import mcp
5
+
6
+
7
+ def main() -> None:
8
+ mcp.run()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -0,0 +1,189 @@
1
+ """CodeChef data via public-page scraping only. No API key, account, or registration.
2
+
3
+ CodeChef has no usable public API, so this parses the public profile page HTML and
4
+ the site's own no-auth contest-list JSON (the same one the website's own contests
5
+ page calls). Scrapers are inherently fragile: every field degrades to None / an
6
+ error dict rather than crashing if CodeChef changes its markup or blocks the request.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ BASE = "https://www.codechef.com"
17
+ _UA = (
18
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
19
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
20
+ )
21
+
22
+ _client: httpx.Client | None = None
23
+
24
+
25
+ def _get_client() -> httpx.Client:
26
+ global _client
27
+ if _client is None:
28
+ _client = httpx.Client(
29
+ timeout=25.0,
30
+ headers={"User-Agent": _UA, "Accept-Language": "en-US,en;q=0.9"},
31
+ follow_redirects=True,
32
+ )
33
+ return _client
34
+
35
+
36
+ def _get_text(url: str) -> str:
37
+ resp = _get_client().get(url)
38
+ resp.raise_for_status()
39
+ return resp.text
40
+
41
+
42
+ def _get_json(url: str) -> Any:
43
+ resp = _get_client().get(url, headers={"x-requested-with": "XMLHttpRequest"})
44
+ resp.raise_for_status()
45
+ return resp.json()
46
+
47
+
48
+ def _stars_for(rating: int | None) -> str | None:
49
+ if rating is None:
50
+ return None
51
+ thresholds = [(1400, 1), (1600, 2), (1800, 3), (2000, 4), (2200, 5), (2500, 6)]
52
+ n = 7
53
+ for limit, stars in thresholds:
54
+ if rating < limit:
55
+ n = stars
56
+ break
57
+ return "★" * n
58
+
59
+
60
+ def _search_int(pattern: str, text: str) -> int | None:
61
+ m = re.search(pattern, text)
62
+ if not m:
63
+ return None
64
+ try:
65
+ return int(m.group(1).replace(",", ""))
66
+ except (ValueError, IndexError):
67
+ return None
68
+
69
+
70
+ def _extract_js_array(text: str, var: str) -> list | None:
71
+ """Return the JSON array literal assigned to `var` in an inline <script>, or None."""
72
+ i = text.find(var)
73
+ if i == -1:
74
+ return None
75
+ start = text.find("[", i)
76
+ if start == -1:
77
+ return None
78
+ depth = 0
79
+ for k in range(start, len(text)):
80
+ c = text[k]
81
+ if c == "[":
82
+ depth += 1
83
+ elif c == "]":
84
+ depth -= 1
85
+ if depth == 0:
86
+ try:
87
+ return json.loads(text[start : k + 1])
88
+ except json.JSONDecodeError:
89
+ return None
90
+ return None
91
+
92
+
93
+ def _looks_like_profile(html: str) -> bool:
94
+ return "rating-number" in html or "rating-ranks" in html
95
+
96
+
97
+ def user_info(handle: str) -> dict[str, Any]:
98
+ html = _get_text(f"{BASE}/users/{handle}")
99
+ if not _looks_like_profile(html):
100
+ return {
101
+ "error": f"Could not read CodeChef profile for '{handle}' "
102
+ "(user not found, page layout changed, or request blocked)."
103
+ }
104
+ rating = _search_int(r'class="rating-number"[^>]*>\s*(\d+)', html)
105
+ highest = _search_int(r"Highest Rating\s*(\d[\d,]*)", html)
106
+
107
+ # rating-ranks block holds global rank then country rank, inside an inline <ul>.
108
+ # A rank is a number for users active in the latest rated contest, else "Inactive".
109
+ global_rank = country_rank = None
110
+ block = re.search(r"rating-ranks(.*?)</ul>", html, re.S)
111
+ if block:
112
+ strongs = re.findall(r"<strong>(.*?)</strong>", block.group(1), re.S)
113
+ vals: list[int | str | None] = []
114
+ for s in strongs[:2]:
115
+ s = s.strip()
116
+ if re.fullmatch(r"[\d,]+", s):
117
+ vals.append(int(s.replace(",", "")))
118
+ elif "inactive" in s.lower():
119
+ vals.append("Inactive")
120
+ else:
121
+ vals.append(None)
122
+ if len(vals) >= 1:
123
+ global_rank = vals[0]
124
+ if len(vals) >= 2:
125
+ country_rank = vals[1]
126
+
127
+ country = None
128
+ m = re.search(r'class="user-country-name"[^>]*>\s*([^<]+?)\s*</', html)
129
+ if m:
130
+ country = m.group(1).strip()
131
+
132
+ return {
133
+ "handle": handle,
134
+ "rating": rating,
135
+ "stars": _stars_for(rating),
136
+ "highestRating": highest,
137
+ "globalRank": global_rank,
138
+ "countryRank": country_rank,
139
+ "country": country,
140
+ "url": f"{BASE}/users/{handle}",
141
+ }
142
+
143
+
144
+ def user_rating(handle: str) -> list[dict[str, Any]]:
145
+ html = _get_text(f"{BASE}/users/{handle}")
146
+ raw = _extract_js_array(html, "all_rating")
147
+ if not raw:
148
+ if not _looks_like_profile(html):
149
+ return [{"error": f"Could not read CodeChef profile for '{handle}'."}]
150
+ return []
151
+ out = []
152
+ for e in raw:
153
+ if not isinstance(e, dict):
154
+ continue
155
+ try:
156
+ rating = int(e["rating"]) if e.get("rating") not in (None, "") else None
157
+ except (ValueError, TypeError):
158
+ rating = None
159
+ try:
160
+ rank = int(e["rank"]) if e.get("rank") not in (None, "") else None
161
+ except (ValueError, TypeError):
162
+ rank = None
163
+ out.append(
164
+ {
165
+ "code": e.get("code"),
166
+ "name": e.get("name"),
167
+ "rating": rating,
168
+ "rank": rank,
169
+ "endDate": e.get("end_date"),
170
+ }
171
+ )
172
+ return out
173
+
174
+
175
+ def upcoming_contests() -> list[dict[str, Any]]:
176
+ data = _get_json(f"{BASE}/api/list/contests/all")
177
+ future = data.get("future_contests", []) if isinstance(data, dict) else []
178
+ out = []
179
+ for c in future:
180
+ out.append(
181
+ {
182
+ "code": c.get("contest_code"),
183
+ "name": c.get("contest_name"),
184
+ "startsUTC": c.get("contest_start_date_iso") or c.get("contest_start_date"),
185
+ "endsUTC": c.get("contest_end_date_iso") or c.get("contest_end_date"),
186
+ "url": f"{BASE}/{c.get('contest_code')}" if c.get("contest_code") else None,
187
+ }
188
+ )
189
+ return out