speedrun-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.
- speedrun_mcp-0.1.0/LICENSE +21 -0
- speedrun_mcp-0.1.0/PKG-INFO +141 -0
- speedrun_mcp-0.1.0/README.md +117 -0
- speedrun_mcp-0.1.0/pyproject.toml +43 -0
- speedrun_mcp-0.1.0/setup.cfg +4 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp/__init__.py +5 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp/__main__.py +4 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp/client.py +230 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp/format.py +252 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp/server.py +275 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp.egg-info/PKG-INFO +141 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp.egg-info/SOURCES.txt +17 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp.egg-info/dependency_links.txt +1 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp.egg-info/entry_points.txt +2 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp.egg-info/requires.txt +7 -0
- speedrun_mcp-0.1.0/src/speedrun_mcp.egg-info/top_level.txt +1 -0
- speedrun_mcp-0.1.0/tests/test_format.py +155 -0
- speedrun_mcp-0.1.0/tests/test_live.py +135 -0
- speedrun_mcp-0.1.0/tests/test_server.py +15 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 William Jeffries
|
|
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.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: speedrun-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Model Context Protocol server for the speedrun.com API — games, leaderboards, world records, players and personal bests.
|
|
5
|
+
Author: William Jeffries
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/williamcodes/speedrun-mcp
|
|
8
|
+
Project-URL: Issues, https://github.com/williamcodes/speedrun-mcp/issues
|
|
9
|
+
Project-URL: speedrun.com API, https://github.com/speedruncomorg/api
|
|
10
|
+
Keywords: mcp,model-context-protocol,speedrun,speedrun.com,llm,claude
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Topic :: Games/Entertainment
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: mcp>=1.2.0
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# speedrun-mcp
|
|
26
|
+
|
|
27
|
+
<!-- mcp-name: io.github.williamcodes/speedrun-mcp -->
|
|
28
|
+
|
|
29
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server for
|
|
30
|
+
[speedrun.com](https://www.speedrun.com). It lets an AI assistant query games,
|
|
31
|
+
categories, leaderboards, world records, players and their personal bests —
|
|
32
|
+
e.g. *"What's the current Super Mario 64 16-star world record, and who holds it?"*
|
|
33
|
+
|
|
34
|
+
Built on speedrun.com's official, public [REST API](https://github.com/speedruncomorg/api).
|
|
35
|
+
**No account or API key required** (the read endpoints are open); results are
|
|
36
|
+
shaped into compact, model-friendly JSON (player ids resolved to names,
|
|
37
|
+
durations formatted, subcategory variables labeled).
|
|
38
|
+
|
|
39
|
+
## Tools
|
|
40
|
+
|
|
41
|
+
| Tool | What it does |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `search_games` | Fuzzy-search games by name → ids & abbreviations |
|
|
44
|
+
| `get_game` | A game's details plus its categories (and optionally levels) |
|
|
45
|
+
| `list_categories` | A game's categories (`Any%`, `120 Star`, …) with rules |
|
|
46
|
+
| `list_variables` | Subcategory/filter variables and their value ids |
|
|
47
|
+
| `list_platforms` / `list_regions` | Platform / region ids for the `platform`/`region` leaderboard filters |
|
|
48
|
+
| `get_leaderboard` | A ranked leaderboard (top N; filter by variable / platform / region / timing) |
|
|
49
|
+
| `get_world_record` | The current #1 run for a game/category, plus any runs tied for first |
|
|
50
|
+
| `search_users` | Find players by username (partial, fuzzy match) |
|
|
51
|
+
| `get_user_personal_bests` | A player's PBs across all games |
|
|
52
|
+
| `get_run` | Details of a single run |
|
|
53
|
+
|
|
54
|
+
A typical flow: `search_games` → `list_categories` (and `list_variables` for
|
|
55
|
+
subcategories) → `get_leaderboard` / `get_world_record`. Use `list_platforms` /
|
|
56
|
+
`list_regions` when you need an id for the `platform` / `region` filters.
|
|
57
|
+
|
|
58
|
+
## Install & run
|
|
59
|
+
|
|
60
|
+
Requires Python 3.10+.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# from PyPI (once published)
|
|
64
|
+
pipx install speedrun-mcp # or: uv tool install speedrun-mcp
|
|
65
|
+
|
|
66
|
+
# from source
|
|
67
|
+
git clone https://github.com/williamcodes/speedrun-mcp
|
|
68
|
+
cd speedrun-mcp
|
|
69
|
+
pip install -e .
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The server speaks MCP over stdio:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
speedrun-mcp # console script
|
|
76
|
+
python -m speedrun_mcp # equivalent
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Use with Claude Desktop / Claude Code
|
|
80
|
+
|
|
81
|
+
Add to your MCP client config (e.g. `claude_desktop_config.json`):
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"mcpServers": {
|
|
86
|
+
"speedrun": {
|
|
87
|
+
"command": "speedrun-mcp"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
If you installed from source into a virtualenv, point `command` at that
|
|
94
|
+
interpreter, e.g. `"command": "/path/to/.venv/bin/speedrun-mcp"`.
|
|
95
|
+
|
|
96
|
+
For Claude Code:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
claude mcp add speedrun -- speedrun-mcp
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Notes & limits
|
|
103
|
+
|
|
104
|
+
- **Read-only.** Submitting or moderating runs requires an authenticated
|
|
105
|
+
speedrun.com session and is intentionally out of scope.
|
|
106
|
+
- **Rate limit:** speedrun.com allows 100 requests/minute per IP and responds
|
|
107
|
+
with HTTP 420 when exceeded; the client surfaces a clear error if you hit it.
|
|
108
|
+
- Game and category arguments accept either an id (`o1y9wo6q`) or an
|
|
109
|
+
abbreviation (`sm64`). For precise subcategory leaderboards (e.g. `16 Star`),
|
|
110
|
+
discover the variable/value ids with `list_variables` and pass
|
|
111
|
+
`variables={variable_id: value_id}`.
|
|
112
|
+
- **Errors are explanatory.** Invalid ids/filters raise an error that includes
|
|
113
|
+
speedrun.com's own message — e.g. passing a `level` to a full-game category
|
|
114
|
+
returns *"The selected category is for full-game runs, but a level was selected."*
|
|
115
|
+
|
|
116
|
+
### Output shape
|
|
117
|
+
|
|
118
|
+
- **Times** reflect the leaderboard's sort timing. When you pass `timing`
|
|
119
|
+
(`realtime` / `realtime_noloads` / `ingame`), the reported `time` /
|
|
120
|
+
`time_seconds` match that ranking, not the game's default timing.
|
|
121
|
+
- **`get_leaderboard`** returns `returned_runs` (the number of runs returned,
|
|
122
|
+
bounded by `top` and ties — not the full board size) and a `runs` list with
|
|
123
|
+
resolved player names, formatted times, and labeled subcategories.
|
|
124
|
+
- **`get_world_record`** returns `world_record` (the place-1 run, or `null` if
|
|
125
|
+
the board is empty) plus `tied` (a list of any other runs sharing first place).
|
|
126
|
+
- **`get_user_personal_bests`** returns `returned` (how many came back, capped by
|
|
127
|
+
`limit`) and `total_available` (the player's true PB count), plus the
|
|
128
|
+
`personal_bests` list with game/category names and resolved players.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pip install -e ".[dev]"
|
|
134
|
+
pytest -m "not network" # unit tests (offline)
|
|
135
|
+
pytest # include live-API tests
|
|
136
|
+
ruff check .
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# speedrun-mcp
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.williamcodes/speedrun-mcp -->
|
|
4
|
+
|
|
5
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server for
|
|
6
|
+
[speedrun.com](https://www.speedrun.com). It lets an AI assistant query games,
|
|
7
|
+
categories, leaderboards, world records, players and their personal bests —
|
|
8
|
+
e.g. *"What's the current Super Mario 64 16-star world record, and who holds it?"*
|
|
9
|
+
|
|
10
|
+
Built on speedrun.com's official, public [REST API](https://github.com/speedruncomorg/api).
|
|
11
|
+
**No account or API key required** (the read endpoints are open); results are
|
|
12
|
+
shaped into compact, model-friendly JSON (player ids resolved to names,
|
|
13
|
+
durations formatted, subcategory variables labeled).
|
|
14
|
+
|
|
15
|
+
## Tools
|
|
16
|
+
|
|
17
|
+
| Tool | What it does |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `search_games` | Fuzzy-search games by name → ids & abbreviations |
|
|
20
|
+
| `get_game` | A game's details plus its categories (and optionally levels) |
|
|
21
|
+
| `list_categories` | A game's categories (`Any%`, `120 Star`, …) with rules |
|
|
22
|
+
| `list_variables` | Subcategory/filter variables and their value ids |
|
|
23
|
+
| `list_platforms` / `list_regions` | Platform / region ids for the `platform`/`region` leaderboard filters |
|
|
24
|
+
| `get_leaderboard` | A ranked leaderboard (top N; filter by variable / platform / region / timing) |
|
|
25
|
+
| `get_world_record` | The current #1 run for a game/category, plus any runs tied for first |
|
|
26
|
+
| `search_users` | Find players by username (partial, fuzzy match) |
|
|
27
|
+
| `get_user_personal_bests` | A player's PBs across all games |
|
|
28
|
+
| `get_run` | Details of a single run |
|
|
29
|
+
|
|
30
|
+
A typical flow: `search_games` → `list_categories` (and `list_variables` for
|
|
31
|
+
subcategories) → `get_leaderboard` / `get_world_record`. Use `list_platforms` /
|
|
32
|
+
`list_regions` when you need an id for the `platform` / `region` filters.
|
|
33
|
+
|
|
34
|
+
## Install & run
|
|
35
|
+
|
|
36
|
+
Requires Python 3.10+.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# from PyPI (once published)
|
|
40
|
+
pipx install speedrun-mcp # or: uv tool install speedrun-mcp
|
|
41
|
+
|
|
42
|
+
# from source
|
|
43
|
+
git clone https://github.com/williamcodes/speedrun-mcp
|
|
44
|
+
cd speedrun-mcp
|
|
45
|
+
pip install -e .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The server speaks MCP over stdio:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
speedrun-mcp # console script
|
|
52
|
+
python -m speedrun_mcp # equivalent
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Use with Claude Desktop / Claude Code
|
|
56
|
+
|
|
57
|
+
Add to your MCP client config (e.g. `claude_desktop_config.json`):
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"speedrun": {
|
|
63
|
+
"command": "speedrun-mcp"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If you installed from source into a virtualenv, point `command` at that
|
|
70
|
+
interpreter, e.g. `"command": "/path/to/.venv/bin/speedrun-mcp"`.
|
|
71
|
+
|
|
72
|
+
For Claude Code:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
claude mcp add speedrun -- speedrun-mcp
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Notes & limits
|
|
79
|
+
|
|
80
|
+
- **Read-only.** Submitting or moderating runs requires an authenticated
|
|
81
|
+
speedrun.com session and is intentionally out of scope.
|
|
82
|
+
- **Rate limit:** speedrun.com allows 100 requests/minute per IP and responds
|
|
83
|
+
with HTTP 420 when exceeded; the client surfaces a clear error if you hit it.
|
|
84
|
+
- Game and category arguments accept either an id (`o1y9wo6q`) or an
|
|
85
|
+
abbreviation (`sm64`). For precise subcategory leaderboards (e.g. `16 Star`),
|
|
86
|
+
discover the variable/value ids with `list_variables` and pass
|
|
87
|
+
`variables={variable_id: value_id}`.
|
|
88
|
+
- **Errors are explanatory.** Invalid ids/filters raise an error that includes
|
|
89
|
+
speedrun.com's own message — e.g. passing a `level` to a full-game category
|
|
90
|
+
returns *"The selected category is for full-game runs, but a level was selected."*
|
|
91
|
+
|
|
92
|
+
### Output shape
|
|
93
|
+
|
|
94
|
+
- **Times** reflect the leaderboard's sort timing. When you pass `timing`
|
|
95
|
+
(`realtime` / `realtime_noloads` / `ingame`), the reported `time` /
|
|
96
|
+
`time_seconds` match that ranking, not the game's default timing.
|
|
97
|
+
- **`get_leaderboard`** returns `returned_runs` (the number of runs returned,
|
|
98
|
+
bounded by `top` and ties — not the full board size) and a `runs` list with
|
|
99
|
+
resolved player names, formatted times, and labeled subcategories.
|
|
100
|
+
- **`get_world_record`** returns `world_record` (the place-1 run, or `null` if
|
|
101
|
+
the board is empty) plus `tied` (a list of any other runs sharing first place).
|
|
102
|
+
- **`get_user_personal_bests`** returns `returned` (how many came back, capped by
|
|
103
|
+
`limit`) and `total_available` (the player's true PB count), plus the
|
|
104
|
+
`personal_bests` list with game/category names and resolved players.
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pip install -e ".[dev]"
|
|
110
|
+
pytest -m "not network" # unit tests (offline)
|
|
111
|
+
pytest # include live-API tests
|
|
112
|
+
ruff check .
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "speedrun-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Model Context Protocol server for the speedrun.com API — games, leaderboards, world records, players and personal bests."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "William Jeffries" }]
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "speedrun", "speedrun.com", "llm", "claude"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Topic :: Games/Entertainment",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"mcp>=1.2.0",
|
|
21
|
+
"httpx>=0.27",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.5"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/williamcodes/speedrun-mcp"
|
|
29
|
+
Issues = "https://github.com/williamcodes/speedrun-mcp/issues"
|
|
30
|
+
"speedrun.com API" = "https://github.com/speedruncomorg/api"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
speedrun-mcp = "speedrun_mcp.server:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
asyncio_mode = "auto"
|
|
40
|
+
markers = ["network: tests that hit the live speedrun.com API"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Thin async client for the speedrun.com REST API (v1).
|
|
2
|
+
|
|
3
|
+
The public API requires no authentication for the read endpoints used here.
|
|
4
|
+
Docs: https://github.com/speedruncomorg/api/tree/master/version1
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
API_BASE = "https://www.speedrun.com/api/v1"
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
_VERSION = version("speedrun-mcp")
|
|
18
|
+
except PackageNotFoundError: # running from a source checkout without install
|
|
19
|
+
_VERSION = "0.0.0+dev"
|
|
20
|
+
|
|
21
|
+
USER_AGENT = f"speedrun-mcp/{_VERSION} (+https://github.com/williamcodes/speedrun-mcp)"
|
|
22
|
+
|
|
23
|
+
# speedrun.com allows 100 requests/min/IP and answers 420 when exceeded.
|
|
24
|
+
RATE_LIMIT_STATUS = 420
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SpeedrunError(RuntimeError):
|
|
28
|
+
"""Raised when the speedrun.com API returns an error we can explain."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RateLimitError(SpeedrunError):
|
|
32
|
+
"""Raised when the API rejects us for exceeding 100 requests/minute."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotFoundError(SpeedrunError):
|
|
36
|
+
"""Raised when the API returns HTTP 404 for a resource (bad id/filters)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SpeedrunClient:
|
|
40
|
+
"""Minimal async wrapper around the speedrun.com API.
|
|
41
|
+
|
|
42
|
+
One client owns one ``httpx.AsyncClient``; use it as an async context
|
|
43
|
+
manager or remember to ``await close()``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, *, timeout: float = 20.0) -> None:
|
|
47
|
+
self._http = httpx.AsyncClient(
|
|
48
|
+
base_url=API_BASE,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
|
51
|
+
follow_redirects=True, # abbreviations 30x-redirect to ID-based URLs
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def __aenter__(self) -> "SpeedrunClient":
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
58
|
+
await self.close()
|
|
59
|
+
|
|
60
|
+
async def close(self) -> None:
|
|
61
|
+
await self._http.aclose()
|
|
62
|
+
|
|
63
|
+
async def _request(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
64
|
+
"""GET a path and return the full parsed JSON body (incl. pagination)."""
|
|
65
|
+
clean = {k: v for k, v in (params or {}).items() if v is not None}
|
|
66
|
+
try:
|
|
67
|
+
resp = await self._http.get(path, params=clean)
|
|
68
|
+
except httpx.HTTPError as exc: # network/DNS/timeout
|
|
69
|
+
raise SpeedrunError(f"Network error talking to speedrun.com: {exc}") from exc
|
|
70
|
+
|
|
71
|
+
if resp.status_code == RATE_LIMIT_STATUS:
|
|
72
|
+
raise RateLimitError(
|
|
73
|
+
"speedrun.com rate limit hit (100 requests/minute). Wait a minute and retry."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if resp.status_code == 404:
|
|
77
|
+
detail = self._error_message(resp)
|
|
78
|
+
msg = f"Not found: {path} (check the id/abbreviation and any filters)."
|
|
79
|
+
if detail:
|
|
80
|
+
msg = f"{msg} speedrun.com says: {detail}"
|
|
81
|
+
raise NotFoundError(msg)
|
|
82
|
+
|
|
83
|
+
if resp.status_code >= 400:
|
|
84
|
+
detail = self._error_message(resp)
|
|
85
|
+
msg = f"speedrun.com returned HTTP {resp.status_code} for {path}."
|
|
86
|
+
if detail:
|
|
87
|
+
msg = f"{msg} speedrun.com says: {detail}"
|
|
88
|
+
raise SpeedrunError(msg)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
return resp.json()
|
|
92
|
+
except ValueError as exc: # non-JSON / empty success body
|
|
93
|
+
raise SpeedrunError(
|
|
94
|
+
f"speedrun.com returned an unparseable response for {path}: {exc}"
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
98
|
+
"""GET a path and return the parsed ``data`` payload.
|
|
99
|
+
|
|
100
|
+
speedrun.com wraps successful responses in ``{"data": ...}``; we unwrap
|
|
101
|
+
it so callers never have to. Pagination metadata is dropped on purpose —
|
|
102
|
+
for single-page tools that cap their own result counts. Use
|
|
103
|
+
:meth:`_get_paginated` for collections that may exceed one page.
|
|
104
|
+
"""
|
|
105
|
+
body = await self._request(path, params)
|
|
106
|
+
if not isinstance(body, dict):
|
|
107
|
+
return body
|
|
108
|
+
return body.get("data", body)
|
|
109
|
+
|
|
110
|
+
async def _get_paginated(self, path: str, params: dict[str, Any] | None = None) -> list[dict]:
|
|
111
|
+
"""Fetch and concatenate ALL pages of a collection endpoint.
|
|
112
|
+
|
|
113
|
+
Some collections (e.g. ``/platforms``, ~235 items) exceed the 200/page
|
|
114
|
+
cap, so a single request silently truncates. This walks every page by
|
|
115
|
+
following the ``pagination.links`` ``next`` marker / incrementing offset.
|
|
116
|
+
"""
|
|
117
|
+
merged = dict(params or {})
|
|
118
|
+
page_size = int(merged.get("max") or 200)
|
|
119
|
+
merged["max"] = page_size
|
|
120
|
+
collected: list[dict] = []
|
|
121
|
+
offset = 0
|
|
122
|
+
while True:
|
|
123
|
+
merged["offset"] = offset
|
|
124
|
+
body = await self._request(path, merged)
|
|
125
|
+
data = body.get("data", []) if isinstance(body, dict) else (body or [])
|
|
126
|
+
collected.extend(data)
|
|
127
|
+
pagination = body.get("pagination", {}) if isinstance(body, dict) else {}
|
|
128
|
+
has_next = any(link.get("rel") == "next" for link in pagination.get("links", []))
|
|
129
|
+
if not has_next or len(data) < page_size: # last (or short/empty) page
|
|
130
|
+
break
|
|
131
|
+
offset += page_size
|
|
132
|
+
return collected
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _error_message(resp: httpx.Response) -> str | None:
|
|
136
|
+
"""Best-effort extraction of the ``message`` field from an error body.
|
|
137
|
+
|
|
138
|
+
speedrun.com error bodies look like
|
|
139
|
+
``{"status":400,"message":"...","links":[...]}``. A non-JSON or empty
|
|
140
|
+
body must not raise here — we just return ``None`` so the caller can
|
|
141
|
+
fall back to a generic message.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
body = resp.json()
|
|
145
|
+
except ValueError:
|
|
146
|
+
return None
|
|
147
|
+
if isinstance(body, dict):
|
|
148
|
+
message = body.get("message")
|
|
149
|
+
if isinstance(message, str) and message.strip():
|
|
150
|
+
return message.strip()
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
# -- games ----------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
async def search_games(self, name: str, *, maximum: int = 10) -> list[dict]:
|
|
156
|
+
return await self._get("/games", {"name": name, "max": maximum})
|
|
157
|
+
|
|
158
|
+
async def get_game(self, game: str, *, embed: str | None = None) -> dict:
|
|
159
|
+
return await self._get(f"/games/{game}", {"embed": embed})
|
|
160
|
+
|
|
161
|
+
async def get_categories(self, game: str) -> list[dict]:
|
|
162
|
+
return await self._get(f"/games/{game}/categories")
|
|
163
|
+
|
|
164
|
+
async def get_levels(self, game: str) -> list[dict]:
|
|
165
|
+
return await self._get(f"/games/{game}/levels")
|
|
166
|
+
|
|
167
|
+
async def get_game_variables(self, game: str) -> list[dict]:
|
|
168
|
+
return await self._get(f"/games/{game}/variables")
|
|
169
|
+
|
|
170
|
+
async def get_category_variables(self, category: str) -> list[dict]:
|
|
171
|
+
return await self._get(f"/categories/{category}/variables")
|
|
172
|
+
|
|
173
|
+
# -- leaderboards ---------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
async def get_leaderboard(
|
|
176
|
+
self,
|
|
177
|
+
game: str,
|
|
178
|
+
category: str,
|
|
179
|
+
*,
|
|
180
|
+
level: str | None = None,
|
|
181
|
+
top: int | None = None,
|
|
182
|
+
variables: dict[str, str] | None = None,
|
|
183
|
+
platform: str | None = None,
|
|
184
|
+
region: str | None = None,
|
|
185
|
+
timing: str | None = None,
|
|
186
|
+
emulators: bool | None = None,
|
|
187
|
+
date: str | None = None,
|
|
188
|
+
embed: str | None = None,
|
|
189
|
+
) -> dict:
|
|
190
|
+
if level:
|
|
191
|
+
path = f"/leaderboards/{game}/level/{level}/{category}"
|
|
192
|
+
else:
|
|
193
|
+
path = f"/leaderboards/{game}/category/{category}"
|
|
194
|
+
params: dict[str, Any] = {
|
|
195
|
+
"top": top,
|
|
196
|
+
"platform": platform,
|
|
197
|
+
"region": region,
|
|
198
|
+
"timing": timing,
|
|
199
|
+
"emulators": emulators,
|
|
200
|
+
"date": date,
|
|
201
|
+
"embed": embed,
|
|
202
|
+
}
|
|
203
|
+
for var_id, value_id in (variables or {}).items():
|
|
204
|
+
params[f"var-{var_id}"] = value_id
|
|
205
|
+
return await self._get(path, params)
|
|
206
|
+
|
|
207
|
+
# -- users / runs ---------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
async def search_users(self, name: str, *, maximum: int = 10) -> list[dict]:
|
|
210
|
+
# 'name' does fuzzy/partial matching; 'lookup' is exact-only.
|
|
211
|
+
return await self._get("/users", {"name": name, "max": maximum})
|
|
212
|
+
|
|
213
|
+
async def get_user(self, user: str) -> dict:
|
|
214
|
+
return await self._get(f"/users/{user}")
|
|
215
|
+
|
|
216
|
+
async def get_user_personal_bests(
|
|
217
|
+
self, user: str, *, embed: str | None = None
|
|
218
|
+
) -> list[dict]:
|
|
219
|
+
return await self._get(f"/users/{user}/personal-bests", {"embed": embed})
|
|
220
|
+
|
|
221
|
+
async def get_run(self, run_id: str, *, embed: str | None = None) -> dict:
|
|
222
|
+
return await self._get(f"/runs/{run_id}", {"embed": embed})
|
|
223
|
+
|
|
224
|
+
# -- platforms / regions --------------------------------------------------
|
|
225
|
+
|
|
226
|
+
async def get_platforms(self) -> list[dict]:
|
|
227
|
+
return await self._get_paginated("/platforms")
|
|
228
|
+
|
|
229
|
+
async def get_regions(self) -> list[dict]:
|
|
230
|
+
return await self._get_paginated("/regions")
|