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,114 @@
|
|
|
1
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
2
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
3
|
+
from transfermarkt_api.tfmkt.reference import get_reference, market_value_of, value_of
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def get_club_profile(tfmkt: TfmktClient, club_id: str) -> dict:
|
|
7
|
+
"""
|
|
8
|
+
Club profile.
|
|
9
|
+
|
|
10
|
+
`officialName`, the address and the colors come from the club's superior (parent) club record. For national
|
|
11
|
+
teams that record is the football association, so `officialName` is omitted for them.
|
|
12
|
+
"""
|
|
13
|
+
club, squad, stadium, club_coach, reference = await gather(
|
|
14
|
+
tfmkt.club(club_id),
|
|
15
|
+
tfmkt.club_squad(club_id),
|
|
16
|
+
tfmkt.club_stadium(club_id),
|
|
17
|
+
tfmkt.club_coach(club_id),
|
|
18
|
+
get_reference(tfmkt),
|
|
19
|
+
)
|
|
20
|
+
coach_id = str(club_coach["coachId"]) if club_coach and club_coach.get("coachId") else None
|
|
21
|
+
coaches = await tfmkt.coaches([coach_id]) if coach_id else {}
|
|
22
|
+
base = club.get("baseDetails") or {}
|
|
23
|
+
superior = base.get("superiorClub") or {}
|
|
24
|
+
location = superior.get("location") or {}
|
|
25
|
+
squad_details = club.get("squadDetails") or {}
|
|
26
|
+
competition_id = base.get("primaryCompetitionId") or None
|
|
27
|
+
competition = (await tfmkt.competitions([competition_id])).get(competition_id, {}) if competition_id else {}
|
|
28
|
+
competition_origin = competition.get("originDetails") or {}
|
|
29
|
+
country = reference.country(base.get("countryId"))
|
|
30
|
+
stadium_location = (stadium or {}).get("location") or {}
|
|
31
|
+
building = (stadium or {}).get("buildingDetails") or {}
|
|
32
|
+
postcode_city = " ".join(part for part in (location.get("postcode"), location.get("city")) if part)
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
"id": club_id,
|
|
36
|
+
"url": club.get("relativeUrl"),
|
|
37
|
+
"name": club.get("name"),
|
|
38
|
+
"shortName": base.get("shortName") or None,
|
|
39
|
+
"abbreviation": base.get("abbreviation") or None,
|
|
40
|
+
"clubCode": (club.get("preferences") or {}).get("clubCode") or None,
|
|
41
|
+
"officialName": None if base.get("isNationalTeam") else superior.get("name") or None,
|
|
42
|
+
"image": club.get("crestUrl"),
|
|
43
|
+
"addressLine1": (location.get("street") or "").strip() or None,
|
|
44
|
+
"addressLine2": postcode_city or None,
|
|
45
|
+
"addressLine3": reference.country_name(location.get("countryId")),
|
|
46
|
+
"colors": [color for color in (superior.get("colors") or {}).values() if color],
|
|
47
|
+
"stadiumName": (stadium or {}).get("name"),
|
|
48
|
+
"stadiumSeats": (stadium or {}).get("capacity"),
|
|
49
|
+
"stadium": {
|
|
50
|
+
"id": str(stadium["id"]) if stadium.get("id") is not None else None,
|
|
51
|
+
"name": stadium.get("name") or None,
|
|
52
|
+
"capacity": stadium.get("capacity"),
|
|
53
|
+
"internationalCapacity": stadium.get("internationalCapacity"),
|
|
54
|
+
"website": stadium.get("url") or None,
|
|
55
|
+
"addressLine1": (stadium_location.get("street") or "").strip() or None,
|
|
56
|
+
"addressLine2": " ".join(
|
|
57
|
+
part for part in (stadium_location.get("postcode"), stadium_location.get("city")) if part
|
|
58
|
+
)
|
|
59
|
+
or None,
|
|
60
|
+
"countryId": str(stadium_location["countryId"]) if stadium_location.get("countryId") is not None else None,
|
|
61
|
+
"countryName": reference.country_name(stadium_location.get("countryId")),
|
|
62
|
+
"latitude": stadium_location.get("latitude"),
|
|
63
|
+
"longitude": stadium_location.get("longitude"),
|
|
64
|
+
"buildYear": building.get("buildYear") or None,
|
|
65
|
+
"renovationYear": building.get("renovationYear") or None,
|
|
66
|
+
"fieldLength": building.get("fieldLength") or None,
|
|
67
|
+
"fieldWidth": building.get("fieldWidth") or None,
|
|
68
|
+
"fieldSurface": building.get("fieldSurface") or None,
|
|
69
|
+
"images": list(dict.fromkeys(image["url"] for image in stadium.get("images") or [] if image.get("url"))),
|
|
70
|
+
}
|
|
71
|
+
if stadium
|
|
72
|
+
else None,
|
|
73
|
+
"currentMarketValue": market_value_of(squad_details.get("currentMarketValue")),
|
|
74
|
+
"confederation": (
|
|
75
|
+
reference.confederation_name(country.get("confederationId")) if base.get("isNationalTeam") else None
|
|
76
|
+
),
|
|
77
|
+
"coach": {
|
|
78
|
+
"id": coach_id,
|
|
79
|
+
"name": coaches.get(coach_id, {}).get("name"),
|
|
80
|
+
"since": (club_coach.get("startDate") or "")[:10] or None,
|
|
81
|
+
}
|
|
82
|
+
if coach_id
|
|
83
|
+
else None,
|
|
84
|
+
"squad": {
|
|
85
|
+
"size": squad_details.get("squadSize"),
|
|
86
|
+
"averageAge": float(squad_details["averageAgeDisplay"]) if squad_details.get("averageAgeDisplay") else None,
|
|
87
|
+
"foreigners": squad.get("foreignCount"),
|
|
88
|
+
"nationalTeamPlayers": squad.get("nationalCount"),
|
|
89
|
+
"domesticPlayers": squad.get("localCount"),
|
|
90
|
+
"averageMarketValue": market_value_of(squad_details.get("averageMarketValue")),
|
|
91
|
+
"acquisitionValue": value_of(squad_details.get("acquisitionValue")),
|
|
92
|
+
"top18PlayersMarketValue": market_value_of(squad_details.get("top18PlayersMarketValue")),
|
|
93
|
+
"top18SharePercentage": (squad_details.get("top18SharePercentage") or {}).get("value"),
|
|
94
|
+
},
|
|
95
|
+
"league": {
|
|
96
|
+
"id": competition_id,
|
|
97
|
+
"name": competition.get("name"),
|
|
98
|
+
"countryId": str(competition_origin["countryId"]) if competition_origin.get("countryId") else None,
|
|
99
|
+
"countryName": reference.country_name(competition_origin.get("countryId")),
|
|
100
|
+
"tier": reference.competition_type_name(competition.get("typeId")),
|
|
101
|
+
},
|
|
102
|
+
"historicalNames": [
|
|
103
|
+
{
|
|
104
|
+
"name": entry.get("name") or None,
|
|
105
|
+
"shortName": entry.get("shortName") or None,
|
|
106
|
+
"abbreviation": entry.get("abbreviation") or None,
|
|
107
|
+
"seasonId": str(entry["seasonId"]) if entry.get("seasonId") is not None else None,
|
|
108
|
+
}
|
|
109
|
+
for entry in (club.get("historical") or {}).get("names") or []
|
|
110
|
+
],
|
|
111
|
+
"historicalCrests": list(
|
|
112
|
+
dict.fromkeys(image["url"] for image in (club.get("historical") or {}).get("images") or [])
|
|
113
|
+
),
|
|
114
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
2
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
3
|
+
from transfermarkt_api.tfmkt.reference import get_reference, last_page_number, market_value_of
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def search_clubs(tfmkt: TfmktClient, query: str, page_number: int) -> dict:
|
|
7
|
+
"""Search clubs by name. Results keep the upstream search ranking."""
|
|
8
|
+
search = await tfmkt.quick_search(query, page_number)
|
|
9
|
+
club_ids = [str(i) for i in search["result"]["clubIds"]]
|
|
10
|
+
|
|
11
|
+
clubs, reference = await gather(tfmkt.clubs(club_ids), get_reference(tfmkt))
|
|
12
|
+
|
|
13
|
+
results = []
|
|
14
|
+
for club_id in club_ids:
|
|
15
|
+
club = clubs.get(club_id, {})
|
|
16
|
+
squad = club.get("squadDetails") or {}
|
|
17
|
+
results.append(
|
|
18
|
+
{
|
|
19
|
+
"id": club_id,
|
|
20
|
+
"url": club.get("relativeUrl"),
|
|
21
|
+
"name": club.get("name"),
|
|
22
|
+
"country": reference.country_name((club.get("baseDetails") or {}).get("countryId")),
|
|
23
|
+
"squad": squad.get("squadSize"),
|
|
24
|
+
"marketValue": market_value_of(squad.get("currentMarketValue")),
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
"query": query,
|
|
30
|
+
"pageNumber": page_number,
|
|
31
|
+
"lastPageNumber": last_page_number(search["totalCount"]["clubs"]),
|
|
32
|
+
"results": results,
|
|
33
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
2
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
3
|
+
from transfermarkt_api.tfmkt.reference import Reference, date_of_birth, full_url, get_reference, nationality_ids
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def coach_summary(coach: dict, reference: Reference) -> dict:
|
|
7
|
+
"""Map an upstream coach record to the public coach fields."""
|
|
8
|
+
birth = coach.get("birthPlaceDetails") or {}
|
|
9
|
+
return {
|
|
10
|
+
"id": str(coach["id"]),
|
|
11
|
+
"url": full_url(coach.get("relativeUrl")),
|
|
12
|
+
"name": coach.get("name"),
|
|
13
|
+
"nameInHomeCountry": (coach.get("nationalityDetails") or {}).get("passportName") or None,
|
|
14
|
+
"imageUrl": coach.get("portraitUrl"),
|
|
15
|
+
"dateOfBirth": date_of_birth(coach),
|
|
16
|
+
"age": (coach.get("lifeDates") or {}).get("age"),
|
|
17
|
+
"placeOfBirth": {
|
|
18
|
+
"city": birth.get("placeOfBirth") or None,
|
|
19
|
+
"country": reference.country_name(birth.get("countryOfBirthId")),
|
|
20
|
+
},
|
|
21
|
+
"citizenship": reference.country_names(*nationality_ids(coach)),
|
|
22
|
+
"license": ((coach.get("attributes") or {}).get("license") or {}).get("name"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def get_coach_profile(tfmkt: TfmktClient, coach_id: str) -> dict:
|
|
27
|
+
"""Coach profile. The upstream record has no current club; use a club profile's `coach` for that."""
|
|
28
|
+
coach, reference = await gather(tfmkt.coach(coach_id), get_reference(tfmkt))
|
|
29
|
+
return coach_summary(coach, reference)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from transfermarkt_api.services.coaches.profile import coach_summary
|
|
2
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
3
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
4
|
+
from transfermarkt_api.tfmkt.reference import get_reference, last_page_number
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def search_coaches(tfmkt: TfmktClient, query: str, page_number: int) -> dict:
|
|
8
|
+
"""Search coaches by name. Results keep the upstream search ranking."""
|
|
9
|
+
search = await tfmkt.quick_search(query, page_number)
|
|
10
|
+
coach_ids = [str(i) for i in search["result"]["coachIds"]]
|
|
11
|
+
coaches, reference = await gather(tfmkt.coaches(coach_ids), get_reference(tfmkt))
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
"query": query,
|
|
15
|
+
"pageNumber": page_number,
|
|
16
|
+
"lastPageNumber": last_page_number(search["totalCount"]["coaches"]),
|
|
17
|
+
"results": [coach_summary(coaches.get(i, {"id": i}), reference) for i in coach_ids],
|
|
18
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from transfermarkt_api.exceptions import NotFound
|
|
4
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
5
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def get_competition_clubs(tfmkt: TfmktClient, competition_id: str, season_id: Optional[str] = None) -> dict:
|
|
9
|
+
"""
|
|
10
|
+
List the clubs that took part in a competition season (current season when `season_id` is omitted).
|
|
11
|
+
|
|
12
|
+
Club names are the clubs' current names; the membership itself is historical.
|
|
13
|
+
"""
|
|
14
|
+
membership, competitions = await gather(
|
|
15
|
+
tfmkt.competition_clubs(competition_id, season_id),
|
|
16
|
+
tfmkt.competitions([competition_id]),
|
|
17
|
+
)
|
|
18
|
+
competition = competitions.get(competition_id)
|
|
19
|
+
if competition is None:
|
|
20
|
+
raise NotFound(f"Competition not found: {competition_id}")
|
|
21
|
+
|
|
22
|
+
club_ids = [str(i) for i in membership["clubIds"]]
|
|
23
|
+
clubs = await tfmkt.clubs(club_ids)
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
"id": competition_id,
|
|
27
|
+
"name": competition["name"],
|
|
28
|
+
"seasonId": str(membership["seasonId"]),
|
|
29
|
+
"clubs": [{"id": club_id, "name": clubs.get(club_id, {}).get("name")} for club_id in club_ids],
|
|
30
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
4
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
5
|
+
from transfermarkt_api.tfmkt.reference import get_reference, last_page_number, value_of
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def search_competitions(tfmkt: TfmktClient, query: str, page_number: int) -> dict:
|
|
9
|
+
"""
|
|
10
|
+
Search competitions by name.
|
|
11
|
+
|
|
12
|
+
`clubs` and `players` count the current season's member clubs and the sum of their squad sizes, and
|
|
13
|
+
`meanMarketValue` is the total market value divided by the number of clubs. This reproduces the website's
|
|
14
|
+
figures (e.g. Premier League 2026: 20 clubs, 542 players).
|
|
15
|
+
"""
|
|
16
|
+
search = await tfmkt.quick_search(query, page_number)
|
|
17
|
+
competition_ids = [str(i) for i in search["result"]["competitionIds"]]
|
|
18
|
+
|
|
19
|
+
competitions, reference, memberships = await gather(
|
|
20
|
+
tfmkt.competitions(competition_ids),
|
|
21
|
+
get_reference(tfmkt),
|
|
22
|
+
# some competitions (e.g. finished tournaments) may have no current membership
|
|
23
|
+
gather(*(tfmkt.get_optional(f"/competition/{i}/club") for i in competition_ids)),
|
|
24
|
+
)
|
|
25
|
+
club_ids_by_competition = {
|
|
26
|
+
i: [str(c) for c in m["clubIds"]] if m else None for i, m in zip(competition_ids, memberships)
|
|
27
|
+
}
|
|
28
|
+
clubs = await tfmkt.clubs(c for ids in club_ids_by_competition.values() for c in ids or [])
|
|
29
|
+
|
|
30
|
+
results = []
|
|
31
|
+
for competition_id in competition_ids:
|
|
32
|
+
competition = competitions.get(competition_id, {})
|
|
33
|
+
club_ids = club_ids_by_competition[competition_id]
|
|
34
|
+
total_market_value = value_of(competition.get("totalMarketValue"))
|
|
35
|
+
origin = competition.get("originDetails") or {}
|
|
36
|
+
results.append(
|
|
37
|
+
{
|
|
38
|
+
"id": competition_id,
|
|
39
|
+
"name": competition.get("name"),
|
|
40
|
+
"country": reference.country_name(origin.get("countryId")),
|
|
41
|
+
"clubs": len(club_ids) if club_ids is not None else None,
|
|
42
|
+
"players": squad_total(clubs, club_ids),
|
|
43
|
+
"totalMarketValue": total_market_value,
|
|
44
|
+
"meanMarketValue": total_market_value // len(club_ids) if total_market_value and club_ids else None,
|
|
45
|
+
"continent": reference.confederation_name(origin.get("confederationId")),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"query": query,
|
|
51
|
+
"pageNumber": page_number,
|
|
52
|
+
"lastPageNumber": last_page_number(search["totalCount"]["competitions"]),
|
|
53
|
+
"results": results,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def squad_total(clubs: dict, club_ids: Optional[list[str]]) -> Optional[int]:
|
|
58
|
+
"""Sum the squad sizes of the given clubs; None when the membership is unknown."""
|
|
59
|
+
if club_ids is None:
|
|
60
|
+
return None
|
|
61
|
+
return sum((clubs.get(c, {}).get("squadDetails") or {}).get("squadSize") or 0 for c in club_ids)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from transfermarkt_api.exceptions import NotFound
|
|
2
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
3
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def get_competition_seasons(tfmkt: TfmktClient, competition_id: str) -> dict:
|
|
7
|
+
"""Seasons available for a competition, most recent first. Use `id` as `season_id` in other endpoints."""
|
|
8
|
+
data, competitions = await gather(
|
|
9
|
+
tfmkt.competition_seasons(competition_id),
|
|
10
|
+
tfmkt.competitions([competition_id]),
|
|
11
|
+
)
|
|
12
|
+
competition = competitions.get(competition_id)
|
|
13
|
+
if competition is None:
|
|
14
|
+
raise NotFound(f"Competition not found: {competition_id}")
|
|
15
|
+
|
|
16
|
+
seasons = sorted(data.get("seasons") or [], key=lambda s: s.get("seasonId") or 0, reverse=True)
|
|
17
|
+
return {
|
|
18
|
+
"id": competition_id,
|
|
19
|
+
"name": competition.get("name"),
|
|
20
|
+
"seasons": [
|
|
21
|
+
{
|
|
22
|
+
"id": str(season["seasonId"]),
|
|
23
|
+
"name": (season.get("season") or {}).get("display"),
|
|
24
|
+
"matchDays": season.get("gameDays"),
|
|
25
|
+
}
|
|
26
|
+
for season in seasons
|
|
27
|
+
],
|
|
28
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from transfermarkt_api.exceptions import NotFound
|
|
4
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
5
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def get_competition_table(tfmkt: TfmktClient, competition_id: str, season_id: Optional[str] = None) -> dict:
|
|
9
|
+
"""
|
|
10
|
+
League table for a season (current season when `season_id` is omitted).
|
|
11
|
+
|
|
12
|
+
Group-stage competitions return one table per group; knockout-only competitions (cups) return no tables.
|
|
13
|
+
"""
|
|
14
|
+
data, competitions = await gather(
|
|
15
|
+
tfmkt.competition_table(competition_id, season_id),
|
|
16
|
+
tfmkt.competitions([competition_id]),
|
|
17
|
+
)
|
|
18
|
+
competition = competitions.get(competition_id)
|
|
19
|
+
if competition is None:
|
|
20
|
+
raise NotFound(f"Competition not found: {competition_id}")
|
|
21
|
+
|
|
22
|
+
tables = data.get("tables") or []
|
|
23
|
+
clubs = await tfmkt.clubs(row["clubId"] for table in tables for row in table.get("clubs") or [])
|
|
24
|
+
# upstream `meta.seasonId` always names the current season, even when an older season was requested
|
|
25
|
+
resolved_season = season_id or str(((data.get("meta") or {}).get("seasonId")) or competition.get("currentSeasonId"))
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
"id": competition_id,
|
|
29
|
+
"name": competition.get("name"),
|
|
30
|
+
"seasonId": resolved_season,
|
|
31
|
+
"tables": [
|
|
32
|
+
{
|
|
33
|
+
"name": (table.get("meta") or {}).get("name"),
|
|
34
|
+
"rows": [table_row(row, clubs) for row in table.get("clubs") or []],
|
|
35
|
+
}
|
|
36
|
+
for table in tables
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def table_row(row: dict, clubs: dict) -> dict:
|
|
42
|
+
"""Map an upstream table row to the public table row fields."""
|
|
43
|
+
games = row.get("game") or {}
|
|
44
|
+
goals = row.get("goal") or {}
|
|
45
|
+
ranking = row.get("ranking") or {}
|
|
46
|
+
club_id = str(row["clubId"])
|
|
47
|
+
return {
|
|
48
|
+
"position": ranking.get("current"),
|
|
49
|
+
"previousPosition": ranking.get("previous"),
|
|
50
|
+
"clubId": club_id,
|
|
51
|
+
"clubName": clubs.get(club_id, {}).get("name"),
|
|
52
|
+
"matches": games.get("totalCount"),
|
|
53
|
+
"wins": games.get("winCount"),
|
|
54
|
+
"draws": games.get("drawCount"),
|
|
55
|
+
"losses": games.get("lossCount"),
|
|
56
|
+
"goalsFor": goals.get("totalCount"),
|
|
57
|
+
"goalsAgainst": goals.get("concededCount"),
|
|
58
|
+
"goalDifference": goals.get("differenceCount"),
|
|
59
|
+
"points": games.get("points"),
|
|
60
|
+
"zone": (row.get("positioning") or {}).get("description") or None,
|
|
61
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Country reference data assembly shared by SDK and server."""
|
|
2
|
+
|
|
3
|
+
from transfermarkt_api.exceptions import UpstreamError
|
|
4
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def list_countries(tfmkt: TfmktClient) -> dict:
|
|
8
|
+
"""List upstream country metadata, including historical entries."""
|
|
9
|
+
countries = (await tfmkt.attributes()).get("countries")
|
|
10
|
+
if not isinstance(countries, list) or not all(isinstance(country, dict) for country in countries):
|
|
11
|
+
raise UpstreamError("Unexpected upstream payload for /attributes countries")
|
|
12
|
+
return {"countries": countries}
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Optional, cast
|
|
3
|
+
|
|
4
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
5
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
6
|
+
from transfermarkt_api.tfmkt.reference import full_url
|
|
7
|
+
|
|
8
|
+
EVENT_TYPES = {"GOAL": "goal", "CARD": "card", "SUBSTITUTE": "substitution"}
|
|
9
|
+
# team stat -> (clubStatistics group, upstream field)
|
|
10
|
+
TEAM_STATS = {
|
|
11
|
+
"possession": ("gameStatistics", "possessionPercentage"),
|
|
12
|
+
"shots": ("goalStatistics", "totalShotAttempts"),
|
|
13
|
+
"shotsOnTarget": ("goalStatistics", "onTargetShotAttempts"),
|
|
14
|
+
"shotsOffTarget": ("goalStatistics", "offTargetShotAttempts"),
|
|
15
|
+
"shotsBlocked": ("goalStatistics", "blockedShotAttempts"),
|
|
16
|
+
"passes": ("passingStatistics", "totalPasses"),
|
|
17
|
+
"accuratePasses": ("passingStatistics", "accuratePasses"),
|
|
18
|
+
"tackles": ("defensiveStatistics", "totalTackles"),
|
|
19
|
+
"tacklesWon": ("defensiveStatistics", "successfulTackles"),
|
|
20
|
+
"clearances": ("defensiveStatistics", "clearances"),
|
|
21
|
+
"saves": ("defensiveStatistics", "saves"),
|
|
22
|
+
"offsides": ("defensiveStatistics", "offsides"),
|
|
23
|
+
"corners": ("setPieceStatistics", "cornersTaken"),
|
|
24
|
+
"foulsCommitted": ("penaltyStatistics", "freeKicksConcededFromFouls"),
|
|
25
|
+
"foulsSuffered": ("penaltyStatistics", "freeKicksWonFromFouls"),
|
|
26
|
+
"yellowCards": ("gameStatistics", "yellowCards"),
|
|
27
|
+
"secondYellowCards": ("gameStatistics", "secondYellowCards"),
|
|
28
|
+
"redCards": ("gameStatistics", "redCards"),
|
|
29
|
+
"penaltiesWon": ("penaltyStatistics", "penaltiesWon"),
|
|
30
|
+
"penaltiesSaved": ("penaltyStatistics", "penaltiesSaved"),
|
|
31
|
+
"ownGoals": ("penaltyStatistics", "ownGoals"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def get_game(tfmkt: TfmktClient, game_id: str) -> dict:
|
|
36
|
+
"""
|
|
37
|
+
A match with both lineups, coaches, score and events.
|
|
38
|
+
|
|
39
|
+
`stats` holds team statistics (possession, shots, passes, ...) when upstream recorded them, mostly for recent
|
|
40
|
+
matches in major competitions; otherwise it is null. Events are goals, cards and substitutions in match order.
|
|
41
|
+
For a goal, `relatedPlayer` is the assist; for a substitution, `player` goes off and `relatedPlayer` comes on.
|
|
42
|
+
"""
|
|
43
|
+
game = await tfmkt.game(game_id)
|
|
44
|
+
base = game.get("baseDetails") or {}
|
|
45
|
+
sides = {"home": game.get("homeClub") or {}, "away": game.get("awayClub") or {}}
|
|
46
|
+
actions = [action for action in game.get("actions") or [] if action.get("type") in EVENT_TYPES]
|
|
47
|
+
lineups = {
|
|
48
|
+
name: {group: (side.get("lineup") or {}).get(group) or [] for group in ("players", "substitutes")}
|
|
49
|
+
for name, side in sides.items()
|
|
50
|
+
}
|
|
51
|
+
player_ids = [p["id"] for lineup in lineups.values() for group in lineup.values() for p in group] + [
|
|
52
|
+
action[key] for action in actions for key in ("activePlayerId", "passivePlayerId") if action.get(key)
|
|
53
|
+
]
|
|
54
|
+
stadium_id = base.get("stadiumId")
|
|
55
|
+
|
|
56
|
+
players, clubs, coaches, stadium = await gather(
|
|
57
|
+
tfmkt.players(player_ids),
|
|
58
|
+
tfmkt.clubs(side.get("clubId") for side in sides.values()),
|
|
59
|
+
tfmkt.coaches(side.get("coachId") for side in sides.values()),
|
|
60
|
+
tfmkt.stadium(stadium_id) if stadium_id else asyncio.sleep(0),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def entity(entity_id: Optional[str], records: dict) -> Optional[dict]:
|
|
64
|
+
"""`{id, name}` for a referenced player, club or coach, or None when there is no ID."""
|
|
65
|
+
if not entity_id:
|
|
66
|
+
return None
|
|
67
|
+
return {"id": str(entity_id), "name": records.get(str(entity_id), {}).get("name")}
|
|
68
|
+
|
|
69
|
+
def lineup_player(player: dict) -> dict:
|
|
70
|
+
"""A lineup entry with the player's name."""
|
|
71
|
+
return {
|
|
72
|
+
**cast(dict, entity(player["id"], players)),
|
|
73
|
+
"shirtNumber": player.get("shirtNumber"),
|
|
74
|
+
"position": (player.get("position") or {}).get("name"),
|
|
75
|
+
"isCaptain": bool(player.get("isCaptain")),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
score = game.get("score") or {}
|
|
79
|
+
competition = base.get("competition") or {}
|
|
80
|
+
date = base.get("date") or {}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"id": str(game.get("id") or game_id),
|
|
84
|
+
"url": full_url(game.get("relativeUrl")),
|
|
85
|
+
"competition": {"id": base.get("competitionId"), "name": competition.get("name")},
|
|
86
|
+
"seasonId": str(base["seasonId"]) if base.get("seasonId") is not None else None,
|
|
87
|
+
"season": (base.get("season") or {}).get("display"),
|
|
88
|
+
"matchDay": base.get("gameDay") or None,
|
|
89
|
+
"date": date.get("dateTimeUTC"),
|
|
90
|
+
"stadium": {"id": str(stadium_id), "name": (stadium or {}).get("name")} if stadium_id else None,
|
|
91
|
+
"attendance": (game.get("extendedDetails") or {}).get("crowdSize") or None,
|
|
92
|
+
**{
|
|
93
|
+
name: {
|
|
94
|
+
**cast(dict, entity(side.get("clubId"), clubs)),
|
|
95
|
+
"score": score.get(name),
|
|
96
|
+
"formation": (side.get("tactic") or {}).get("tactic"),
|
|
97
|
+
"coach": entity(side.get("coachId"), coaches),
|
|
98
|
+
"stats": team_stats(side.get("clubStatistics")),
|
|
99
|
+
"startingLineup": [lineup_player(p) for p in lineups[name]["players"]],
|
|
100
|
+
"substitutes": [lineup_player(p) for p in lineups[name]["substitutes"]],
|
|
101
|
+
}
|
|
102
|
+
for name, side in sides.items()
|
|
103
|
+
},
|
|
104
|
+
"events": [
|
|
105
|
+
{
|
|
106
|
+
"type": EVENT_TYPES[action["type"]],
|
|
107
|
+
"minute": action.get("minute"),
|
|
108
|
+
"addedTime": action.get("addedTime") or None,
|
|
109
|
+
"clubId": str(action["clubId"]) if action.get("clubId") else None,
|
|
110
|
+
"player": entity(action.get("activePlayerId"), players),
|
|
111
|
+
"relatedPlayer": entity(action.get("passivePlayerId"), players),
|
|
112
|
+
"card": card_type(action) if action["type"] == "CARD" else None,
|
|
113
|
+
"score": action.get("score") if action["type"] == "GOAL" else None,
|
|
114
|
+
}
|
|
115
|
+
for action in sorted(actions, key=lambda a: (a.get("minute") or 0, a.get("addedTime") or 0))
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def card_type(action: dict) -> Optional[str]:
|
|
121
|
+
"""Card colour, read from the season counter upstream increments (`seasonYellowCard`, `seasonRedCard`, ...)."""
|
|
122
|
+
keys = " ".join((action.get("details") or {}).keys())
|
|
123
|
+
if "YellowRed" in keys:
|
|
124
|
+
return "secondYellow"
|
|
125
|
+
if "Red" in keys:
|
|
126
|
+
return "red"
|
|
127
|
+
if "Yellow" in keys:
|
|
128
|
+
return "yellow"
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def team_stats(statistics: Optional[dict]) -> Optional[dict]:
|
|
133
|
+
"""A side's match statistics, or None when upstream has none for the match."""
|
|
134
|
+
if not statistics:
|
|
135
|
+
return None
|
|
136
|
+
return {name: (statistics.get(group) or {}).get(field) for name, (group, field) in TEAM_STATS.items()}
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from transfermarkt_api.services.players.injuries import PAGE_SIZE, inclusive_days
|
|
2
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
3
|
+
from transfermarkt_api.tfmkt.reference import last_page_number, season_label
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def get_player_absences(tfmkt: TfmktClient, player_id: str, page_number: int) -> dict:
|
|
7
|
+
"""
|
|
8
|
+
Non-injury absences (suspensions, national-team call-ups, leave), most recent first, paginated like injuries.
|
|
9
|
+
|
|
10
|
+
`days` counts both the first and last day, as for injuries.
|
|
11
|
+
"""
|
|
12
|
+
absences = (await tfmkt.player_absences(player_id)).get("absences") or []
|
|
13
|
+
page = absences[(page_number - 1) * PAGE_SIZE : page_number * PAGE_SIZE]
|
|
14
|
+
competitions = await tfmkt.competitions(a.get("competitionId") for a in page if a.get("competitionId"))
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
"id": player_id,
|
|
18
|
+
"pageNumber": page_number,
|
|
19
|
+
"lastPageNumber": last_page_number(len(absences), PAGE_SIZE),
|
|
20
|
+
"absences": [
|
|
21
|
+
{
|
|
22
|
+
"season": season_label(absence.get("seasonId")),
|
|
23
|
+
"reason": absence.get("name"),
|
|
24
|
+
"competitionId": absence.get("competitionId") or None,
|
|
25
|
+
"competitionName": competitions.get(absence.get("competitionId") or "", {}).get("name"),
|
|
26
|
+
"fromDate": absence.get("start"),
|
|
27
|
+
"untilDate": absence.get("end"),
|
|
28
|
+
"days": inclusive_days(absence),
|
|
29
|
+
"gamesMissed": absence.get("missedGamesCount"),
|
|
30
|
+
}
|
|
31
|
+
for absence in page
|
|
32
|
+
],
|
|
33
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from transfermarkt_api.services.achievements import group_achievements
|
|
2
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
3
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def get_player_achievements(tfmkt: TfmktClient, player_id: str) -> dict:
|
|
7
|
+
"""Titles and individual awards, including participations, runner-up and third places."""
|
|
8
|
+
# the achievement route answers 200 with no data for unknown players; the player lookup gives a proper 404
|
|
9
|
+
_, data = await gather(tfmkt.player(player_id), tfmkt.player_achievements(player_id))
|
|
10
|
+
return {"id": player_id, "achievements": await group_achievements(tfmkt, data)}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
5
|
+
from transfermarkt_api.tfmkt.reference import last_page_number, season_label
|
|
6
|
+
|
|
7
|
+
PAGE_SIZE = 15 # the website's injury history page size, kept for pagination compatibility
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def get_player_injuries(tfmkt: TfmktClient, player_id: str, page_number: int) -> dict:
|
|
11
|
+
"""
|
|
12
|
+
Injury history, most recent first, paginated locally.
|
|
13
|
+
|
|
14
|
+
`days` counts both the first and last day, as the website does (upstream `durationDetails.days` is one less).
|
|
15
|
+
"""
|
|
16
|
+
injuries = (await tfmkt.player_injuries(player_id)).get("injuries") or []
|
|
17
|
+
page = injuries[(page_number - 1) * PAGE_SIZE : page_number * PAGE_SIZE]
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
"id": player_id,
|
|
21
|
+
"pageNumber": page_number,
|
|
22
|
+
"lastPageNumber": last_page_number(len(injuries), PAGE_SIZE),
|
|
23
|
+
"injuries": [
|
|
24
|
+
{
|
|
25
|
+
"season": season_label(injury.get("seasonId")),
|
|
26
|
+
"injury": injury.get("name"),
|
|
27
|
+
"fromDate": injury.get("start"),
|
|
28
|
+
"untilDate": injury.get("end"),
|
|
29
|
+
"days": inclusive_days(injury),
|
|
30
|
+
"gamesMissed": injury.get("missedGamesCount"),
|
|
31
|
+
}
|
|
32
|
+
for injury in page
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def inclusive_days(injury: dict) -> Optional[int]:
|
|
38
|
+
"""Injury length in days, counting both the first and the last day."""
|
|
39
|
+
if injury.get("start") and injury.get("end"):
|
|
40
|
+
return (date.fromisoformat(injury["end"]) - date.fromisoformat(injury["start"])).days + 1
|
|
41
|
+
days = (injury.get("durationDetails") or {}).get("days")
|
|
42
|
+
return days + 1 if days is not None else None
|