transfermarkt-api 5.0.0__py3-none-any.whl
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.
- transfermarkt_api/__init__.py +23 -0
- transfermarkt_api/_version.py +3 -0
- transfermarkt_api/cache.py +20 -0
- transfermarkt_api/client.py +226 -0
- transfermarkt_api/exceptions.py +31 -0
- transfermarkt_api/py.typed +0 -0
- transfermarkt_api/schemas/__init__.py +24 -0
- transfermarkt_api/schemas/achievements.py +25 -0
- transfermarkt_api/schemas/base.py +17 -0
- transfermarkt_api/schemas/clubs/__init__.py +3 -0
- transfermarkt_api/schemas/clubs/listing.py +14 -0
- transfermarkt_api/schemas/clubs/players.py +30 -0
- transfermarkt_api/schemas/clubs/profile.py +82 -0
- transfermarkt_api/schemas/clubs/search.py +19 -0
- transfermarkt_api/schemas/coaches/__init__.py +31 -0
- transfermarkt_api/schemas/competitions/__init__.py +4 -0
- transfermarkt_api/schemas/competitions/clubs.py +15 -0
- transfermarkt_api/schemas/competitions/search.py +21 -0
- transfermarkt_api/schemas/competitions/seasons.py +15 -0
- transfermarkt_api/schemas/competitions/table.py +31 -0
- transfermarkt_api/schemas/countries.py +16 -0
- transfermarkt_api/schemas/games/__init__.py +83 -0
- transfermarkt_api/schemas/players/__init__.py +9 -0
- transfermarkt_api/schemas/players/absences.py +22 -0
- transfermarkt_api/schemas/players/injuries.py +20 -0
- transfermarkt_api/schemas/players/market_value.py +19 -0
- transfermarkt_api/schemas/players/matches.py +56 -0
- transfermarkt_api/schemas/players/national_career.py +21 -0
- transfermarkt_api/schemas/players/profile.py +81 -0
- transfermarkt_api/schemas/players/search.py +25 -0
- transfermarkt_api/schemas/players/stats.py +24 -0
- transfermarkt_api/schemas/players/transfers.py +38 -0
- transfermarkt_api/services/__init__.py +0 -0
- transfermarkt_api/services/achievements.py +50 -0
- transfermarkt_api/services/clubs/__init__.py +0 -0
- transfermarkt_api/services/clubs/achievements.py +10 -0
- transfermarkt_api/services/clubs/listing.py +22 -0
- transfermarkt_api/services/clubs/players.py +88 -0
- transfermarkt_api/services/clubs/profile.py +114 -0
- transfermarkt_api/services/clubs/search.py +33 -0
- transfermarkt_api/services/coaches/__init__.py +0 -0
- transfermarkt_api/services/coaches/profile.py +29 -0
- transfermarkt_api/services/coaches/search.py +18 -0
- transfermarkt_api/services/competitions/__init__.py +0 -0
- transfermarkt_api/services/competitions/clubs.py +30 -0
- transfermarkt_api/services/competitions/search.py +61 -0
- transfermarkt_api/services/competitions/seasons.py +28 -0
- transfermarkt_api/services/competitions/table.py +61 -0
- transfermarkt_api/services/countries.py +12 -0
- transfermarkt_api/services/games/__init__.py +0 -0
- transfermarkt_api/services/games/game.py +136 -0
- transfermarkt_api/services/players/__init__.py +0 -0
- transfermarkt_api/services/players/absences.py +33 -0
- transfermarkt_api/services/players/achievements.py +10 -0
- transfermarkt_api/services/players/injuries.py +42 -0
- transfermarkt_api/services/players/market_value.py +35 -0
- transfermarkt_api/services/players/matches.py +107 -0
- transfermarkt_api/services/players/national_career.py +31 -0
- transfermarkt_api/services/players/profile.py +122 -0
- transfermarkt_api/services/players/search.py +44 -0
- transfermarkt_api/services/players/stats.py +74 -0
- transfermarkt_api/services/players/transfers.py +89 -0
- transfermarkt_api/tfmkt/__init__.py +1 -0
- transfermarkt_api/tfmkt/client.py +249 -0
- transfermarkt_api/tfmkt/concurrency.py +17 -0
- transfermarkt_api/tfmkt/freshness.py +38 -0
- transfermarkt_api/tfmkt/reference.py +116 -0
- transfermarkt_api-5.0.0.dist-info/METADATA +234 -0
- transfermarkt_api-5.0.0.dist-info/RECORD +71 -0
- transfermarkt_api-5.0.0.dist-info/WHEEL +4 -0
- transfermarkt_api-5.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Async client for Transfermarkt's JSON API (tfmkt), the data source of every endpoint migrated off HTML scraping."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from cachetools import TTLCache
|
|
10
|
+
|
|
11
|
+
from transfermarkt_api.cache import CacheConfig
|
|
12
|
+
from transfermarkt_api.exceptions import NotFound, UpstreamBlocked, UpstreamError, UpstreamTimeout
|
|
13
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
14
|
+
from transfermarkt_api.tfmkt.freshness import record_fetch
|
|
15
|
+
|
|
16
|
+
BATCH_SIZE = 100 # batch routes accepted 200 IDs in testing; stay well below
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TfmktClient:
|
|
20
|
+
"""
|
|
21
|
+
Thin wrapper around tfmkt routes.
|
|
22
|
+
|
|
23
|
+
Every request goes through `get`, which applies the concurrency cap, the response cache and the upstream error
|
|
24
|
+
mapping. Successful responses are cached with the time they were fetched, so callers can report data age.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
base_url: str = "https://tmapi.transfermarkt.technology",
|
|
30
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
31
|
+
*,
|
|
32
|
+
timeout: float | httpx.Timeout | None = 15.0,
|
|
33
|
+
max_concurrency: int = 10,
|
|
34
|
+
cache: CacheConfig | None = CacheConfig(),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Create the HTTP client, concurrency cap and response caches."""
|
|
37
|
+
url = httpx.URL(base_url)
|
|
38
|
+
if url.scheme not in ("http", "https") or not url.host or url.query or url.fragment:
|
|
39
|
+
raise ValueError("base_url must be an absolute HTTP(S) URL without query or fragment")
|
|
40
|
+
if not isinstance(max_concurrency, int) or isinstance(max_concurrency, bool) or max_concurrency < 1:
|
|
41
|
+
raise ValueError("max_concurrency must be a positive integer")
|
|
42
|
+
self._transport_injected = transport is not None
|
|
43
|
+
self.http = httpx.AsyncClient(
|
|
44
|
+
base_url=base_url,
|
|
45
|
+
headers={"Accept": "application/json", "User-Agent": "transfermarkt-api"},
|
|
46
|
+
timeout=timeout,
|
|
47
|
+
transport=transport,
|
|
48
|
+
)
|
|
49
|
+
self.semaphore = asyncio.Semaphore(max_concurrency)
|
|
50
|
+
self.cache: TTLCache | None = TTLCache(maxsize=cache.max_entries, ttl=cache.ttl) if cache else None
|
|
51
|
+
self.reference_cache: TTLCache | None = TTLCache(maxsize=8, ttl=cache.reference_ttl) if cache else None
|
|
52
|
+
|
|
53
|
+
async def aclose(self) -> None:
|
|
54
|
+
"""Close the underlying HTTP client."""
|
|
55
|
+
await self.http.aclose()
|
|
56
|
+
|
|
57
|
+
async def get(self, path: str, params: Optional[list[tuple[str, Any]]] = None, reference: bool = False) -> Any:
|
|
58
|
+
"""
|
|
59
|
+
Fetch a tfmkt route and return its `data` payload.
|
|
60
|
+
|
|
61
|
+
Exceptions from an injected transport propagate unchanged, including httpx errors.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
TransfermarktError: 404 when upstream does not know the entity, 503 when upstream challenges or rejects the
|
|
65
|
+
request, 504 on timeout and 502 for any other unexpected upstream response.
|
|
66
|
+
"""
|
|
67
|
+
if self.http.is_closed:
|
|
68
|
+
raise RuntimeError("Transfermarkt client is closed")
|
|
69
|
+
request = self.http.build_request("GET", path, params=params)
|
|
70
|
+
key = str(request.url)
|
|
71
|
+
cache = self.reference_cache if reference else self.cache
|
|
72
|
+
if cache is not None and key in cache:
|
|
73
|
+
fetched_at, data = cache[key]
|
|
74
|
+
record_fetch(fetched_at)
|
|
75
|
+
return data
|
|
76
|
+
|
|
77
|
+
async with self.semaphore:
|
|
78
|
+
try:
|
|
79
|
+
response = await self.http.send(request)
|
|
80
|
+
except httpx.TimeoutException as e:
|
|
81
|
+
if self._transport_injected:
|
|
82
|
+
raise
|
|
83
|
+
raise UpstreamTimeout(f"Upstream timeout for {path}", url=str(request.url)) from e
|
|
84
|
+
except httpx.HTTPError as e:
|
|
85
|
+
if self._transport_injected:
|
|
86
|
+
raise
|
|
87
|
+
raise UpstreamError(f"Upstream connection error for {path}: {e}", url=str(request.url)) from e
|
|
88
|
+
|
|
89
|
+
data = self._parse(response, path)
|
|
90
|
+
fetched_at = response.extensions.get("transfermarkt_fetched_at", datetime.now(UTC).replace(tzinfo=None))
|
|
91
|
+
if not isinstance(fetched_at, datetime):
|
|
92
|
+
raise UpstreamError("transfermarkt_fetched_at must be a datetime", url=key)
|
|
93
|
+
if fetched_at.utcoffset() is not None:
|
|
94
|
+
fetched_at = fetched_at.astimezone(UTC).replace(tzinfo=None)
|
|
95
|
+
if cache is not None:
|
|
96
|
+
cache[key] = (fetched_at, data)
|
|
97
|
+
record_fetch(fetched_at)
|
|
98
|
+
return data
|
|
99
|
+
|
|
100
|
+
async def get_optional(self, path: str, params: Optional[list[tuple[str, Any]]] = None) -> Any:
|
|
101
|
+
"""Like `get`, but returns None when upstream answers 404 (e.g. a club without a stadium)."""
|
|
102
|
+
try:
|
|
103
|
+
return await self.get(path, params=params)
|
|
104
|
+
except NotFound:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _parse(response: httpx.Response, path: str) -> Any:
|
|
109
|
+
"""Return the `data` payload of a tfmkt response, or raise the TransfermarktError matching the failure."""
|
|
110
|
+
metadata = {"url": str(response.request.url), "status_code": response.status_code}
|
|
111
|
+
if response.headers.get("x-amzn-waf-action") or (response.status_code == 202 and not response.content):
|
|
112
|
+
raise UpstreamBlocked(f"Upstream blocked the request (WAF challenge) for {path}", **metadata)
|
|
113
|
+
if response.status_code == 404:
|
|
114
|
+
raise NotFound(f"Not found: {path}", **metadata)
|
|
115
|
+
if response.status_code in (403, 429):
|
|
116
|
+
raise UpstreamBlocked(f"Upstream refused the request ({response.status_code})", **metadata)
|
|
117
|
+
if response.status_code != 200:
|
|
118
|
+
raise UpstreamError(f"Unexpected upstream status {response.status_code}", **metadata)
|
|
119
|
+
try:
|
|
120
|
+
body = response.json()
|
|
121
|
+
except ValueError:
|
|
122
|
+
raise UpstreamError(f"Upstream returned invalid JSON for {path}", **metadata)
|
|
123
|
+
if not isinstance(body, dict) or not body.get("success") or "data" not in body:
|
|
124
|
+
raise UpstreamError(f"Unexpected upstream payload for {path}", **metadata)
|
|
125
|
+
return body["data"]
|
|
126
|
+
|
|
127
|
+
async def get_batch(self, route: str, ids: Iterable[str | int | None]) -> dict[str, dict]:
|
|
128
|
+
"""
|
|
129
|
+
Fetch entities from a batch route (`/players`, `/clubs`, `/competitions`) and index them by ID.
|
|
130
|
+
|
|
131
|
+
IDs that upstream does not return are simply absent from the result; callers must handle the gap.
|
|
132
|
+
"""
|
|
133
|
+
unique_ids = sorted({str(i) for i in ids if i not in (None, "")})
|
|
134
|
+
chunks = [unique_ids[i : i + BATCH_SIZE] for i in range(0, len(unique_ids), BATCH_SIZE)]
|
|
135
|
+
results = await gather(*(self.get(route, params=[("ids[]", i) for i in chunk]) for chunk in chunks))
|
|
136
|
+
return {str(entity["id"]): entity for data in results for entity in data or []}
|
|
137
|
+
|
|
138
|
+
async def attributes(self) -> dict:
|
|
139
|
+
"""Reference data: countries, positions, competition types, confederations, injuries, etc."""
|
|
140
|
+
return await self.get("/attributes", reference=True)
|
|
141
|
+
|
|
142
|
+
async def quick_search(self, term: str, page: int) -> dict:
|
|
143
|
+
"""Search players, clubs, competitions and coaches; returns IDs per category."""
|
|
144
|
+
return await self.get("/quick-search", params=[("term", term), ("page", page)])
|
|
145
|
+
|
|
146
|
+
async def player(self, player_id: str) -> dict:
|
|
147
|
+
"""Fetch a player record."""
|
|
148
|
+
return await self.get(f"/player/{player_id}")
|
|
149
|
+
|
|
150
|
+
async def player_market_value_history(self, player_id: str) -> dict:
|
|
151
|
+
"""Fetch a player's market value history and current value."""
|
|
152
|
+
return await self.get(f"/player/{player_id}/market-value-history")
|
|
153
|
+
|
|
154
|
+
async def player_transfer_history(self, player_id: str) -> Optional[dict]:
|
|
155
|
+
"""None when the player has no transfer history (upstream answers 404)."""
|
|
156
|
+
return await self.get_optional(f"/transfer/history/player/{player_id}")
|
|
157
|
+
|
|
158
|
+
async def player_injuries(self, player_id: str) -> dict:
|
|
159
|
+
"""Fetch a player's injury history."""
|
|
160
|
+
return await self.get(f"/player/{player_id}/injury")
|
|
161
|
+
|
|
162
|
+
async def player_performance_games(self, player_id: str) -> dict:
|
|
163
|
+
"""Fetch one performance record per match of the player's club."""
|
|
164
|
+
return await self.get(f"/player/{player_id}/performance-game")
|
|
165
|
+
|
|
166
|
+
async def player_achievements(self, player_id: str) -> dict:
|
|
167
|
+
"""Fetch a player's titles and awards (one record per title won)."""
|
|
168
|
+
return await self.get(f"/player/{player_id}/achievement")
|
|
169
|
+
|
|
170
|
+
async def player_market_value_ranking(self, player_id: str) -> Optional[dict]:
|
|
171
|
+
"""None when the player has no ranking (upstream answers 404)."""
|
|
172
|
+
return await self.get_optional(f"/player/{player_id}/market-value-ranking")
|
|
173
|
+
|
|
174
|
+
async def player_national_career(self, player_id: str) -> dict:
|
|
175
|
+
"""Fetch the national teams a player has played for."""
|
|
176
|
+
return await self.get(f"/player/{player_id}/national-career-history")
|
|
177
|
+
|
|
178
|
+
async def player_absences(self, player_id: str) -> dict:
|
|
179
|
+
"""Fetch a player's non-injury absences."""
|
|
180
|
+
return await self.get(f"/player/{player_id}/absence")
|
|
181
|
+
|
|
182
|
+
async def coach(self, coach_id: str) -> dict:
|
|
183
|
+
"""Fetch a coach record."""
|
|
184
|
+
return await self.get(f"/coach/{coach_id}")
|
|
185
|
+
|
|
186
|
+
async def coaches(self, ids: Iterable[str | int | None]) -> dict[str, dict]:
|
|
187
|
+
"""Fetch coach records by ID, indexed by ID."""
|
|
188
|
+
return await self.get_batch("/coaches", ids)
|
|
189
|
+
|
|
190
|
+
async def country_clubs(self, country_id: int) -> dict:
|
|
191
|
+
"""Fetch the limited club directory for a country; no upstream pagination is available."""
|
|
192
|
+
return await self.get(f"/country/{country_id}/club")
|
|
193
|
+
|
|
194
|
+
async def club(self, club_id: str) -> dict:
|
|
195
|
+
"""Fetch a club record."""
|
|
196
|
+
return await self.get(f"/club/{club_id}")
|
|
197
|
+
|
|
198
|
+
async def club_squad(self, club_id: str, season_id: Optional[str] = None) -> dict:
|
|
199
|
+
"""Fetch a club's squad for a season (current squad by default)."""
|
|
200
|
+
return await self.get(f"/club/{club_id}/squad", params=_season(season_id))
|
|
201
|
+
|
|
202
|
+
async def club_achievements(self, club_id: str) -> dict:
|
|
203
|
+
"""Fetch a club's titles (one record per title won)."""
|
|
204
|
+
return await self.get(f"/club/{club_id}/achievement")
|
|
205
|
+
|
|
206
|
+
async def club_coach(self, club_id: str) -> Optional[dict]:
|
|
207
|
+
"""None when the club has no head coach on record (upstream answers 404)."""
|
|
208
|
+
return await self.get_optional(f"/club/{club_id}/coach")
|
|
209
|
+
|
|
210
|
+
async def club_stadium(self, club_id: str) -> Optional[dict]:
|
|
211
|
+
"""None when the club has no stadium (upstream answers 404)."""
|
|
212
|
+
return await self.get_optional(f"/club/{club_id}/stadium")
|
|
213
|
+
|
|
214
|
+
async def competition_clubs(self, competition_id: str, season_id: Optional[str] = None) -> dict:
|
|
215
|
+
"""Fetch the member clubs of a competition season (current by default)."""
|
|
216
|
+
return await self.get(f"/competition/{competition_id}/club", params=_season(season_id))
|
|
217
|
+
|
|
218
|
+
async def competition_table(self, competition_id: str, season_id: Optional[str] = None) -> dict:
|
|
219
|
+
"""Fetch a competition's league table(s) for a season (current by default)."""
|
|
220
|
+
return await self.get(f"/competition/{competition_id}/table", params=_season(season_id))
|
|
221
|
+
|
|
222
|
+
async def competition_seasons(self, competition_id: str) -> dict:
|
|
223
|
+
"""Fetch the seasons available for a competition."""
|
|
224
|
+
return await self.get(f"/competition/{competition_id}/season")
|
|
225
|
+
|
|
226
|
+
async def game(self, game_id: str) -> dict:
|
|
227
|
+
"""Fetch a match: lineups, events, score and officials."""
|
|
228
|
+
return await self.get(f"/game/{game_id}")
|
|
229
|
+
|
|
230
|
+
async def stadium(self, stadium_id: str) -> Optional[dict]:
|
|
231
|
+
"""None when the stadium is unknown (upstream answers 404)."""
|
|
232
|
+
return await self.get_optional(f"/stadium/{stadium_id}")
|
|
233
|
+
|
|
234
|
+
async def competitions(self, ids: Iterable[str | int | None]) -> dict[str, dict]:
|
|
235
|
+
"""Fetch competition records by ID, indexed by ID."""
|
|
236
|
+
return await self.get_batch("/competitions", ids)
|
|
237
|
+
|
|
238
|
+
async def clubs(self, ids: Iterable[str | int | None]) -> dict[str, dict]:
|
|
239
|
+
"""Fetch club records by ID, indexed by ID."""
|
|
240
|
+
return await self.get_batch("/clubs", ids)
|
|
241
|
+
|
|
242
|
+
async def players(self, ids: Iterable[str | int | None]) -> dict[str, dict]:
|
|
243
|
+
"""Fetch player records by ID, indexed by ID."""
|
|
244
|
+
return await self.get_batch("/players", ids)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _season(season_id: Optional[str]) -> Optional[list[tuple[str, str]]]:
|
|
248
|
+
"""Query parameters selecting a season, or None for the current season."""
|
|
249
|
+
return [("season", season_id)] if season_id else None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Ordered fan-out with cleanup before failures leave the SDK."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def gather(*awaitables: Awaitable[Any]) -> list[Any]:
|
|
9
|
+
"""Return results in input order, cancelling and awaiting siblings on any failure."""
|
|
10
|
+
tasks = [asyncio.ensure_future(awaitable) for awaitable in awaitables]
|
|
11
|
+
try:
|
|
12
|
+
return await asyncio.gather(*tasks)
|
|
13
|
+
except BaseException:
|
|
14
|
+
for task in tasks:
|
|
15
|
+
task.cancel()
|
|
16
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
17
|
+
raise
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Track when the upstream data behind a response was fetched, so `updatedAt` stays truthful when served from cache.
|
|
3
|
+
|
|
4
|
+
`fetch_scope()` starts a per-call record before assembly and model validation. The client reports
|
|
5
|
+
each response it uses, fresh or cached, and `data_fetched_at()` returns the oldest fetch time. The record is a
|
|
6
|
+
mutable list shared through a context variable, so fetches made inside `asyncio.gather` child tasks are seen by
|
|
7
|
+
the request.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from contextvars import ContextVar
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
_fetch_times: ContextVar[Optional[list[datetime]]] = ContextVar("tfmkt_fetch_times", default=None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def fetch_scope() -> Iterator[None]:
|
|
21
|
+
"""Isolate one assembly operation, restoring the calling context even on failure."""
|
|
22
|
+
token = _fetch_times.set([])
|
|
23
|
+
try:
|
|
24
|
+
yield
|
|
25
|
+
finally:
|
|
26
|
+
_fetch_times.reset(token)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def record_fetch(fetched_at: datetime) -> None:
|
|
30
|
+
"""Record when a response used by the current request was fetched from upstream."""
|
|
31
|
+
fetch_times = _fetch_times.get()
|
|
32
|
+
if fetch_times is not None:
|
|
33
|
+
fetch_times.append(fetched_at)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def data_fetched_at() -> datetime:
|
|
37
|
+
"""Oldest fetch time recorded for the current request, or now when nothing was fetched."""
|
|
38
|
+
return min(_fetch_times.get() or [], default=None) or datetime.now(UTC).replace(tzinfo=None)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Lookups into tfmkt reference data (`/attributes`) and small helpers shared by JSON-backed services."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any, Optional, Union
|
|
5
|
+
|
|
6
|
+
from transfermarkt_api.tfmkt.client import TfmktClient
|
|
7
|
+
|
|
8
|
+
TRANSFERMARKT_URL = "https://www.transfermarkt.com"
|
|
9
|
+
RETIRED_CLUB_ID = "123"
|
|
10
|
+
SEARCH_PAGE_SIZE = 10 # quick-search returns 10 IDs per category per page
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Reference:
|
|
14
|
+
"""Indexed view of `/attributes`."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, attributes: dict) -> None:
|
|
17
|
+
"""Index the `/attributes` lists by ID."""
|
|
18
|
+
self.countries = {c["id"]: c for c in attributes.get("countries", [])}
|
|
19
|
+
self.confederations = {c["id"]: c for c in attributes.get("confederations", [])}
|
|
20
|
+
self.competition_types = {c["id"]: c for c in attributes.get("competitionTypes", [])}
|
|
21
|
+
self.contracts = {c["id"]: c for c in attributes.get("contracts", [])}
|
|
22
|
+
self.positions = {p["id"]: p for p in attributes.get("positions", [])}
|
|
23
|
+
|
|
24
|
+
def country(self, country_id: Optional[Union[int, str]]) -> dict:
|
|
25
|
+
"""Record of a country, or an empty dict if unknown."""
|
|
26
|
+
return self.countries.get(_int(country_id)) or {}
|
|
27
|
+
|
|
28
|
+
def country_name(self, country_id: Optional[Union[int, str]]) -> Optional[str]:
|
|
29
|
+
"""Name of a country, or None if unknown."""
|
|
30
|
+
return self.country(country_id).get("name")
|
|
31
|
+
|
|
32
|
+
def country_names(self, *country_ids: Optional[Union[int, str]]) -> list[str]:
|
|
33
|
+
"""Names of the known countries among the given IDs, in order."""
|
|
34
|
+
return [name for name in (self.country_name(i) for i in country_ids) if name]
|
|
35
|
+
|
|
36
|
+
def confederation_name(self, confederation_id: Optional[Union[int, str]]) -> Optional[str]:
|
|
37
|
+
"""Short name of a confederation (e.g. UEFA), or None if unknown."""
|
|
38
|
+
return self.confederations.get(_int(confederation_id), {}).get("name")
|
|
39
|
+
|
|
40
|
+
def competition_type_name(self, type_id: Optional[Union[int, str]]) -> Optional[str]:
|
|
41
|
+
"""Name of a competition type (e.g. First Tier), or None if unknown."""
|
|
42
|
+
return self.competition_types.get(_int(type_id), {}).get("name")
|
|
43
|
+
|
|
44
|
+
def position_name(self, position_id: Optional[Union[int, str]]) -> Optional[str]:
|
|
45
|
+
"""Name of a position (e.g. Right Winger), or None if unknown."""
|
|
46
|
+
return self.positions.get(_int(position_id), {}).get("name")
|
|
47
|
+
|
|
48
|
+
def contract_option_name(self, option_id: Optional[Union[int, str]]) -> Optional[str]:
|
|
49
|
+
"""Name of a contract option, or None if unknown."""
|
|
50
|
+
return self.contracts.get(_int(option_id), {}).get("name")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def get_reference(tfmkt: TfmktClient) -> Reference:
|
|
54
|
+
"""Fetch `/attributes` (cached) and index it."""
|
|
55
|
+
return Reference(await tfmkt.attributes())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _int(value: Any) -> Optional[int]:
|
|
59
|
+
"""Convert to int, or None when the value is not a number."""
|
|
60
|
+
try:
|
|
61
|
+
return int(value)
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def value_of(money: Optional[dict]) -> Optional[int]:
|
|
67
|
+
"""Numeric value of a tfmkt money object (`{"value": 15000000, "currency": "EUR", ...}`)."""
|
|
68
|
+
if not money or money.get("value") is None:
|
|
69
|
+
return None
|
|
70
|
+
return int(money["value"])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def market_value_of(money: Optional[dict]) -> Optional[int]:
|
|
74
|
+
"""Like `value_of`, but 0 means "no valuation" (the website shows "-"), so it becomes None."""
|
|
75
|
+
return value_of(money) or None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def full_url(relative_url: Optional[str]) -> Optional[str]:
|
|
79
|
+
"""Absolute website URL for a relative upstream URL."""
|
|
80
|
+
return f"{TRANSFERMARKT_URL}{relative_url}" if relative_url else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def season_label(season_id: Optional[Union[int, str]]) -> Optional[str]:
|
|
84
|
+
"""Split-year season label used by the website, e.g. 2025 -> "25/26"."""
|
|
85
|
+
year = _int(season_id)
|
|
86
|
+
if year is None:
|
|
87
|
+
return None
|
|
88
|
+
return f"{year % 100:02d}/{(year + 1) % 100:02d}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def last_page_number(total: Optional[int], page_size: int = SEARCH_PAGE_SIZE) -> int:
|
|
92
|
+
"""Number of pages needed for `total` results (at least 1)."""
|
|
93
|
+
return max(1, math.ceil((total or 0) / page_size))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def current_assignment(player: dict) -> Optional[dict]:
|
|
97
|
+
"""The player's current club assignment. Selected by type: national-team assignments can come first."""
|
|
98
|
+
return next((a for a in player.get("clubAssignments") or [] if a.get("type") == "current"), None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def date_of_birth(player: dict) -> Optional[str]:
|
|
102
|
+
"""ISO date of birth, or None when upstream marks it unknown."""
|
|
103
|
+
life = player.get("lifeDates") or {}
|
|
104
|
+
return None if life.get("isDateOfBirthUnknown") else life.get("dateOfBirth")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def height_cm(player: dict) -> Optional[int]:
|
|
108
|
+
"""Height in centimetres (upstream gives metres)."""
|
|
109
|
+
height = (player.get("attributes") or {}).get("height")
|
|
110
|
+
return round(height * 100) if height else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def nationality_ids(player: dict) -> tuple:
|
|
114
|
+
"""First and second nationality country IDs."""
|
|
115
|
+
nationalities = (player.get("nationalityDetails") or {}).get("nationalities") or {}
|
|
116
|
+
return nationalities.get("nationalityId"), nationalities.get("secondNationalityId")
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: transfermarkt-api
|
|
3
|
+
Version: 5.0.0
|
|
4
|
+
Summary: Async Python SDK and HTTP service for Transfermarkt data
|
|
5
|
+
Author-email: Felipe Allegretti <felipe@allegretti.me>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: <3.14,>=3.12
|
|
9
|
+
Requires-Dist: cachetools<8,>=7.2
|
|
10
|
+
Requires-Dist: httpx<1,>=0.28.1
|
|
11
|
+
Requires-Dist: pydantic<3,>=2.13
|
|
12
|
+
Provides-Extra: server
|
|
13
|
+
Requires-Dist: fastapi<1,>=0.142.2; extra == 'server'
|
|
14
|
+
Requires-Dist: limits<6,>=5.8; extra == 'server'
|
|
15
|
+
Requires-Dist: pydantic-settings<3,>=2.15; extra == 'server'
|
|
16
|
+
Requires-Dist: uvicorn[standard]<1,>=0.54; extra == 'server'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# transfermarkt-api
|
|
20
|
+
|
|
21
|
+
An async Python SDK and RESTful API (FastAPI) for football data from [Transfermarkt](https://www.transfermarkt.com/): players, clubs and competitions.
|
|
22
|
+
|
|
23
|
+
Since version 4.0.0, data comes from Transfermarkt's JSON API (`tmapi.transfermarkt.technology`), the same API the website uses, instead of scraping HTML pages. The website blocks requests from most cloud hosts, which broke v3 deployments; the JSON API does not. See [CHANGELOG.md](CHANGELOG.md) for what changed in the response format.
|
|
24
|
+
|
|
25
|
+
Please note that the deployed application is used only for testing purposes and is rate limited to 2 requests
|
|
26
|
+
every 3 seconds per client IP. If you'd like to customize it, consider hosting in your own cloud service.
|
|
27
|
+
|
|
28
|
+
The JSON API is internal to Transfermarkt and undocumented: it can change without notice. This project's MIT license covers its code, not the data; check [Transfermarkt's terms](https://www.transfermarkt.com/intern/) before using the data in your own services.
|
|
29
|
+
|
|
30
|
+
### Python SDK
|
|
31
|
+
|
|
32
|
+
Install the library without server dependencies:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install transfermarkt-api
|
|
36
|
+
# Until the SDK release is published, install from this repository:
|
|
37
|
+
# pip install .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Release setup and the publishing procedure are documented in [Python package releases](docs/releases.md).
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
|
|
45
|
+
from transfermarkt_api import Transfermarkt
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def main() -> None:
|
|
49
|
+
async with Transfermarkt() as tm:
|
|
50
|
+
squad = await tm.club_players("985")
|
|
51
|
+
for player in squad.players:
|
|
52
|
+
print(player.shirt_number, player.name, player.position, player.market_value)
|
|
53
|
+
print(squad.model_dump(mode="json", by_alias=True))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
asyncio.run(main())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The facade has one method per data endpoint: `player_profile`, `player_transfers`, `club_players`,
|
|
60
|
+
`competition_clubs`, `competition_table`, `game`, and the remaining methods listed in the
|
|
61
|
+
[SDK method table](docs/plans/0001-python-sdk.md#public-contract). Methods return typed Pydantic models exported
|
|
62
|
+
from `transfermarkt_api.schemas`. Nested models remain available in their resource modules. Use string entity
|
|
63
|
+
and season IDs, integer country IDs, and 1-based page numbers. Calls return one page; callers control pagination.
|
|
64
|
+
|
|
65
|
+
Constructor settings are explicit: `base_url`, `timeout` (seconds, `httpx.Timeout`, or `None`),
|
|
66
|
+
`max_concurrency` (default `10`), `transport`, and `cache`. The SDK reads no `.env` or environment settings on import.
|
|
67
|
+
`CacheConfig(ttl=600, reference_ttl=86400, max_entries=2000)` configures per-client caching. Pass `cache=None` to
|
|
68
|
+
disable both response and reference caches so every request reaches the transport.
|
|
69
|
+
|
|
70
|
+
One client supports concurrent calls within one event loop. Its async context manager, or explicit `aclose()`,
|
|
71
|
+
closes the HTTP client and supplied transport. Do not share a supplied transport across client lifetimes unless
|
|
72
|
+
your wrapper owns and controls its closure. Cache hits remain local to each SDK instance.
|
|
73
|
+
|
|
74
|
+
The SDK raises `NotFound`, `UpstreamBlocked`, `UpstreamTimeout`, `UpstreamError`, or `EndpointUnavailable`, all
|
|
75
|
+
derived from `TransfermarktError` and exported at the package root. Available upstream URL/status metadata lives
|
|
76
|
+
on `error.url` and `error.status_code`; the status is the upstream status, not the server's mapped HTTP status.
|
|
77
|
+
The unsupported `player_jersey_numbers` method raises `EndpointUnavailable` without fetching data.
|
|
78
|
+
Invalid constructor/page/country arguments raise `ValueError`. Custom transport exceptions propagate unchanged.
|
|
79
|
+
|
|
80
|
+
### Custom transports and replay
|
|
81
|
+
|
|
82
|
+
Supply an `httpx.AsyncBaseTransport` to control every upstream request, including reference lookups, batches,
|
|
83
|
+
and per-player fan-out. The SDK bounds in-flight concurrency; the caller's transport owns rate limits, retries,
|
|
84
|
+
proxy selection, recording and circuit breakers. The SDK performs no automatic retries.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import httpx
|
|
88
|
+
|
|
89
|
+
from transfermarkt_api import Transfermarkt
|
|
90
|
+
|
|
91
|
+
# Run inside an async function. A custom browser-capable backend can implement the same transport interface.
|
|
92
|
+
async with Transfermarkt(
|
|
93
|
+
transport=httpx.AsyncHTTPTransport(proxy="http://127.0.0.1:8080"),
|
|
94
|
+
cache=None,
|
|
95
|
+
) as tm:
|
|
96
|
+
squad = await tm.club_players("985", season_id="2024")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
[Recording and replay examples](examples/transports.py) wrap a caller-selected backend and save the response
|
|
100
|
+
before SDK parsing. Persist the method, full URL with repeated query parameters, status, relevant headers (including
|
|
101
|
+
`x-amzn-waf-action`), decoded body, and original fetch timestamp. The example store is in memory; replace it with
|
|
102
|
+
durable storage in your application. When reconstructing decoded responses, drop content-encoding/content-length
|
|
103
|
+
headers so the body is not decoded twice.
|
|
104
|
+
|
|
105
|
+
A replay transport returns an `httpx.Response` with
|
|
106
|
+
`extensions={"transfermarkt_fetched_at": captured_datetime}`. This optional metadata accepts naive or timezone-aware
|
|
107
|
+
datetimes. Aware inputs are converted to UTC; naive inputs are interpreted as UTC. `updatedAt` retains its existing
|
|
108
|
+
JSON shape without a timezone suffix and always represents UTC. Without metadata, it reports the current UTC fetch
|
|
109
|
+
time. Each model reports the oldest fetch time used in that call, including cache hits. Exact replay
|
|
110
|
+
needs the same response bodies and timestamps. Missing recordings must raise a transport error rather than fetch
|
|
111
|
+
live data. Batch URLs are stable; concurrent request completion order is not part of the replay key.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from examples.transports import RecordingTransport, ReplayTransport
|
|
115
|
+
|
|
116
|
+
records = {}
|
|
117
|
+
async with Transfermarkt(transport=RecordingTransport(httpx.AsyncHTTPTransport(), records), cache=None) as tm:
|
|
118
|
+
captured = await tm.club_players("985")
|
|
119
|
+
async with Transfermarkt(transport=ReplayTransport(records), cache=None) as tm:
|
|
120
|
+
replayed = await tm.club_players("985")
|
|
121
|
+
assert captured == replayed
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The examples and HTTP server are repository files. The wheel installs only the `transfermarkt_api` package,
|
|
125
|
+
avoiding collisions with a consumer's own `app` package. The `server` extra installs the server dependencies;
|
|
126
|
+
run `uvicorn app.main:app` from a repository checkout, or use the Docker image.
|
|
127
|
+
|
|
128
|
+
### API Swagger
|
|
129
|
+
https://transfermarkt-api.fly.dev/
|
|
130
|
+
|
|
131
|
+
### Endpoints
|
|
132
|
+
|
|
133
|
+
| Endpoint | Notes |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `GET /players/search/{player_name}?page_number=` | 10 results per page |
|
|
136
|
+
| `GET /players/{player_id}/profile` | |
|
|
137
|
+
| `GET /players/{player_id}/market_value` | |
|
|
138
|
+
| `GET /players/{player_id}/transfers` | Transfer type, age, contract dates, remaining contract days, and each club's league and country at the time |
|
|
139
|
+
| `GET /players/{player_id}/stats` | Per season, competition and club; club competitions only |
|
|
140
|
+
| `GET /players/{player_id}/matches?season_id=&page_number=` | Past matches of the player's teams, most recent first, 50 per page; no upcoming fixtures |
|
|
141
|
+
| `GET /players/{player_id}/injuries?page_number=` | 15 injuries per page |
|
|
142
|
+
| `GET /players/{player_id}/absences?page_number=` | Suspensions, call-ups, leave; 15 per page |
|
|
143
|
+
| `GET /players/{player_id}/achievements` | Titles and awards with season, club and competition |
|
|
144
|
+
| `GET /players/{player_id}/national_career` | Senior and youth national teams |
|
|
145
|
+
| `GET /players/{player_id}/jersey_numbers` | `501`: no data source |
|
|
146
|
+
| `GET /countries/` | Country IDs and metadata, including historical entries; cached for 24 hours |
|
|
147
|
+
| `GET /clubs/?country_id=` | Available club IDs and names for a country; limited upstream coverage, no pagination |
|
|
148
|
+
| `GET /clubs/search/{club_name}?page_number=` | 10 results per page |
|
|
149
|
+
| `GET /clubs/{club_id}/profile` | Squad value details, historical names and stadium details |
|
|
150
|
+
| `GET /clubs/{club_id}/players?season_id=` | Current squad by default; shirt number and captaincy for the selected season |
|
|
151
|
+
| `GET /clubs/{club_id}/achievements` | Club titles by season |
|
|
152
|
+
| `GET /coaches/search/{coach_name}?page_number=` | 10 results per page |
|
|
153
|
+
| `GET /coaches/{coach_id}/profile` | |
|
|
154
|
+
| `GET /competitions/search/{competition_name}?page_number=` | 10 results per page |
|
|
155
|
+
| `GET /competitions/{competition_id}/clubs?season_id=` | Current season by default |
|
|
156
|
+
| `GET /competitions/{competition_id}/table?season_id=` | League table; one table per group for group stages |
|
|
157
|
+
| `GET /competitions/{competition_id}/seasons` | Valid `season_id` values |
|
|
158
|
+
| `GET /games/{game_id}` | Score, lineups, coaches, stadium, team stats, goals, cards and substitutions |
|
|
159
|
+
|
|
160
|
+
Errors: `404` unknown ID, `501` endpoint without data source, `502` unexpected upstream response, `503` upstream blocked or refused the request, `504` upstream timeout.
|
|
161
|
+
|
|
162
|
+
Fields without an upstream value are returned as `null`. `updatedAt` is when the underlying data was fetched from Transfermarkt; responses are cached for 10 minutes by default.
|
|
163
|
+
|
|
164
|
+
### Running Locally
|
|
165
|
+
|
|
166
|
+
Requires [uv](https://docs.astral.sh/uv/).
|
|
167
|
+
|
|
168
|
+
````bash
|
|
169
|
+
# Clone the repository
|
|
170
|
+
$ git clone https://github.com/felipeall/transfermarkt-api.git
|
|
171
|
+
|
|
172
|
+
# Go to the project's root folder
|
|
173
|
+
$ cd transfermarkt-api
|
|
174
|
+
|
|
175
|
+
# Install Python 3.12 and the dependencies
|
|
176
|
+
$ uv sync --extra server
|
|
177
|
+
|
|
178
|
+
# Start the API server
|
|
179
|
+
$ uv run uvicorn app.main:app --reload
|
|
180
|
+
|
|
181
|
+
# Access the API local page
|
|
182
|
+
$ open http://localhost:8000/
|
|
183
|
+
````
|
|
184
|
+
|
|
185
|
+
### Running Tests
|
|
186
|
+
|
|
187
|
+
````bash
|
|
188
|
+
# Offline tests (recorded upstream responses, no network)
|
|
189
|
+
$ uv run --extra server pytest
|
|
190
|
+
|
|
191
|
+
# Live smoke tests against Transfermarkt's JSON API
|
|
192
|
+
$ uv run --extra server pytest -m live
|
|
193
|
+
|
|
194
|
+
# Re-record upstream fixtures and response snapshots
|
|
195
|
+
$ rm -rf tests/fixtures/tfmkt && uv run python scripts/capture_fixtures.py
|
|
196
|
+
````
|
|
197
|
+
|
|
198
|
+
### Running via Docker
|
|
199
|
+
|
|
200
|
+
````bash
|
|
201
|
+
# Clone the repository
|
|
202
|
+
$ git clone https://github.com/felipeall/transfermarkt-api.git
|
|
203
|
+
|
|
204
|
+
# Go to the project's root folder
|
|
205
|
+
$ cd transfermarkt-api
|
|
206
|
+
|
|
207
|
+
# Build the Docker image
|
|
208
|
+
$ docker build -t transfermarkt-api .
|
|
209
|
+
|
|
210
|
+
# Instantiate the Docker container
|
|
211
|
+
$ docker run -d -p 8000:8000 transfermarkt-api
|
|
212
|
+
|
|
213
|
+
# Access the API local page
|
|
214
|
+
$ open http://localhost:8000/
|
|
215
|
+
````
|
|
216
|
+
|
|
217
|
+
### Environment Variables
|
|
218
|
+
|
|
219
|
+
| Variable | Description | Default |
|
|
220
|
+
|-------------------------------|--------------------------------------------------------------------------------------------------------|------------------------------------------|
|
|
221
|
+
| `RATE_LIMITING_ENABLE` | Enable rate limiting feature for API calls | `false` |
|
|
222
|
+
| `RATE_LIMITING_FREQUENCY` | Requests allowed per client IP across all routes, in [limits notation](https://limits.readthedocs.io/en/stable/quickstart.html#rate-limit-string-notation) | `2/3seconds` |
|
|
223
|
+
| `TFMKT_BASE_URL` | Transfermarkt JSON API base URL | `https://tmapi.transfermarkt.technology` |
|
|
224
|
+
| `TFMKT_TIMEOUT_SECONDS` | Timeout for each upstream request | `15` |
|
|
225
|
+
| `TFMKT_MAX_CONCURRENCY` | Maximum concurrent upstream requests across the whole server | `10` |
|
|
226
|
+
| `CACHE_TTL_SECONDS` | How long successful upstream responses are cached | `600` |
|
|
227
|
+
| `CACHE_REFERENCE_TTL_SECONDS` | Cache duration for reference data (countries, positions, ...) | `86400` |
|
|
228
|
+
| `CACHE_MAX_ENTRIES` | Maximum number of cached upstream responses | `2000` |
|
|
229
|
+
|
|
230
|
+
Rate limits apply to the API routes only: `/health`, the docs (`/`, `/docs`, `/redoc`) and `/openapi.json` are not
|
|
231
|
+
counted. They are counted per client IP: the `Fly-Client-IP` header set by the Fly.io proxy, or the address of the
|
|
232
|
+
connection when that header is missing. `X-Forwarded-For` is not trusted. Behind another reverse proxy, all
|
|
233
|
+
requests share the proxy's address, and outside Fly.io clients can send `Fly-Client-IP` themselves, so adjust
|
|
234
|
+
`client_ip` in `app/main.py` before enabling rate limiting on another host.
|