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,35 @@
|
|
|
1
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
2
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
3
|
+
from transfermarkt_api.tfmkt.reference import market_value_of
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def get_player_market_value(tfmkt: TfmktClient, player_id: str) -> dict:
|
|
7
|
+
"""
|
|
8
|
+
Current market value, valuation history (oldest first) and worldwide market-value ranking.
|
|
9
|
+
|
|
10
|
+
Club names are the clubs' current names. Only the worldwide ranking is available upstream; the v3 position,
|
|
11
|
+
club and country rankings have no JSON source.
|
|
12
|
+
"""
|
|
13
|
+
data, ranking = await gather(
|
|
14
|
+
tfmkt.player_market_value_history(player_id),
|
|
15
|
+
tfmkt.player_market_value_ranking(player_id),
|
|
16
|
+
)
|
|
17
|
+
history = sorted(data.get("history") or [], key=lambda e: (e.get("marketValue") or {}).get("determined") or "")
|
|
18
|
+
clubs = await tfmkt.clubs(entry.get("clubId") for entry in history)
|
|
19
|
+
worldwide = (ranking or {}).get("ranking")
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
"id": player_id,
|
|
23
|
+
"marketValue": market_value_of((data.get("current") or {}).get("marketValue")),
|
|
24
|
+
"marketValueHistory": [
|
|
25
|
+
{
|
|
26
|
+
"age": entry.get("age"),
|
|
27
|
+
"date": (entry.get("marketValue") or {}).get("determined"),
|
|
28
|
+
"clubId": str(entry["clubId"]) if entry.get("clubId") else None,
|
|
29
|
+
"clubName": clubs.get(str(entry.get("clubId")), {}).get("name"),
|
|
30
|
+
"marketValue": market_value_of(entry.get("marketValue")),
|
|
31
|
+
}
|
|
32
|
+
for entry in history
|
|
33
|
+
],
|
|
34
|
+
"ranking": {"Worldwide": worldwide} if worldwide else None,
|
|
35
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
|
6
|
+
|
|
7
|
+
PAGE_SIZE = 50
|
|
8
|
+
# upstream participationState -> participation; other values are passed through unchanged
|
|
9
|
+
PARTICIPATION = {
|
|
10
|
+
"played": "played",
|
|
11
|
+
"in squad": "onBench",
|
|
12
|
+
"not in squad": "notInSquad",
|
|
13
|
+
"injured": "injured",
|
|
14
|
+
"absent": "absent",
|
|
15
|
+
}
|
|
16
|
+
# detailed stat -> (statistics group, upstream field); null when upstream did not record it for the match
|
|
17
|
+
DETAILED_STATS = {
|
|
18
|
+
"ownGoals": ("goalStatistics", "ownGoalsScored"),
|
|
19
|
+
"penaltyGoals": ("goalStatistics", "penaltyShooterGoalsScored"),
|
|
20
|
+
"penaltiesMissed": ("goalStatistics", "penaltyShooterMisses"),
|
|
21
|
+
"penaltiesSaved": ("goalStatistics", "penaltyGoalkeeperSaves"),
|
|
22
|
+
"shots": ("goalStatistics", "scoringAttempts"),
|
|
23
|
+
"shotsOnTarget": ("goalStatistics", "scoringAttemptsOnGoal"),
|
|
24
|
+
"passes": ("distributionStatistics", "passes"),
|
|
25
|
+
"accuratePasses": ("distributionStatistics", "passesReached"),
|
|
26
|
+
"tackles": ("duelStatistics", "tackles"),
|
|
27
|
+
"tacklesWon": ("duelStatistics", "tacklesWon"),
|
|
28
|
+
"foulsCommitted": ("duelStatistics", "foulsCommitted"),
|
|
29
|
+
"foulsSuffered": ("duelStatistics", "foulsGained"),
|
|
30
|
+
"offsides": ("duelStatistics", "offsides"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def get_player_matches(
|
|
35
|
+
tfmkt: TfmktClient,
|
|
36
|
+
player_id: str,
|
|
37
|
+
page_number: int,
|
|
38
|
+
season_id: Optional[str] = None,
|
|
39
|
+
) -> dict:
|
|
40
|
+
"""
|
|
41
|
+
Matches of the player's teams, most recent first, paginated, with the player's part in each.
|
|
42
|
+
|
|
43
|
+
Includes matches the player missed (`participation` tells why) and national-team matches. Detailed statistics
|
|
44
|
+
(shots, passes, tackles, ...) are null when upstream did not record them, which is common before 2018. Only past
|
|
45
|
+
matches are available upstream; there is no fixture list.
|
|
46
|
+
"""
|
|
47
|
+
performance = (await tfmkt.player_performance_games(player_id))["performance"]
|
|
48
|
+
games = [
|
|
49
|
+
game
|
|
50
|
+
for game in performance
|
|
51
|
+
if season_id is None or str(game["gameInformation"].get("seasonId")) == str(season_id)
|
|
52
|
+
]
|
|
53
|
+
games.sort(key=lambda game: (game["gameInformation"].get("date") or {}).get("dateTimeUTC") or "", reverse=True)
|
|
54
|
+
page = games[(page_number - 1) * PAGE_SIZE : page_number * PAGE_SIZE]
|
|
55
|
+
|
|
56
|
+
competitions, clubs, reference = await gather(
|
|
57
|
+
tfmkt.competitions(game["gameInformation"]["competitionId"] for game in page),
|
|
58
|
+
tfmkt.clubs(game["clubsInformation"][side]["clubId"] for game in page for side in ("club", "opponent")),
|
|
59
|
+
get_reference(tfmkt),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def entity(entity_id: str, records: dict) -> dict:
|
|
63
|
+
"""`{id, name}` for a referenced club or competition."""
|
|
64
|
+
return {"id": str(entity_id), "name": records.get(str(entity_id), {}).get("name")}
|
|
65
|
+
|
|
66
|
+
matches = []
|
|
67
|
+
for game in page:
|
|
68
|
+
info, teams, stats = game["gameInformation"], game["clubsInformation"], game["statistics"]
|
|
69
|
+
general = stats["generalStatistics"]
|
|
70
|
+
state = general.get("participationState")
|
|
71
|
+
cards = stats.get("cardStatistics") or {}
|
|
72
|
+
goals = stats.get("goalStatistics") or {}
|
|
73
|
+
playing_time = stats.get("playingTimeStatistics") or {}
|
|
74
|
+
matches.append({
|
|
75
|
+
"gameId": str(info["gameId"]),
|
|
76
|
+
"date": (info.get("date") or {}).get("dateTimeUTC"),
|
|
77
|
+
"season": (info.get("season") or {}).get("display"),
|
|
78
|
+
"competition": entity(info["competitionId"], competitions),
|
|
79
|
+
"matchDay": info.get("gameDay") or None,
|
|
80
|
+
"club": entity(teams["club"]["clubId"], clubs),
|
|
81
|
+
"opponent": entity(teams["opponent"]["clubId"], clubs),
|
|
82
|
+
"venue": teams["club"].get("venue"),
|
|
83
|
+
"clubGoals": teams["club"].get("goalsTotal"),
|
|
84
|
+
"opponentGoals": teams["opponent"].get("goalsTotal"),
|
|
85
|
+
"participation": PARTICIPATION.get(state, state),
|
|
86
|
+
"shirtNumber": general.get("shirtNumber"),
|
|
87
|
+
"isCaptain": bool(general.get("isCaptain")),
|
|
88
|
+
"position": reference.position_name(general.get("positionId")),
|
|
89
|
+
"isStarting": bool(playing_time.get("isStarting")),
|
|
90
|
+
"substitutedInMinute": (playing_time.get("substitutedIn") or {}).get("minute"),
|
|
91
|
+
"substitutedOutMinute": (playing_time.get("substitutedOut") or {}).get("minute"),
|
|
92
|
+
"minutesPlayed": playing_time.get("playedMinutes") or 0,
|
|
93
|
+
"teamPoints": teams["club"].get("points"),
|
|
94
|
+
"goals": goals.get("goalsScoredTotal") or 0,
|
|
95
|
+
"assists": goals.get("assists") or 0,
|
|
96
|
+
"yellowCard": bool(cards.get("yellowCard")),
|
|
97
|
+
"secondYellowCard": bool(cards.get("yellowRedCard")),
|
|
98
|
+
"redCard": bool(cards.get("redCard")),
|
|
99
|
+
**{name: (stats.get(group) or {}).get(field) for name, (group, field) in DETAILED_STATS.items()},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"id": player_id,
|
|
104
|
+
"pageNumber": page_number,
|
|
105
|
+
"lastPageNumber": last_page_number(len(games), PAGE_SIZE),
|
|
106
|
+
"matches": matches,
|
|
107
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
2
|
+
|
|
3
|
+
CAREER_STATUS = {
|
|
4
|
+
"CURRENT_NATIONAL_PLAYER": "current",
|
|
5
|
+
"RECENT_NATIONAL_PLAYER": "recent",
|
|
6
|
+
"FORMER_NATIONAL_PLAYER": "former",
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def get_player_national_career(tfmkt: TfmktClient, player_id: str) -> dict:
|
|
11
|
+
"""Senior and youth national teams the player has played for, in upstream order (most recent team first)."""
|
|
12
|
+
history = (await tfmkt.player_national_career(player_id)).get("history") or []
|
|
13
|
+
clubs = await tfmkt.clubs(entry.get("clubId") for entry in history)
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
"id": player_id,
|
|
17
|
+
"nationalTeams": [
|
|
18
|
+
{
|
|
19
|
+
"id": str(entry["clubId"]),
|
|
20
|
+
"name": clubs.get(str(entry["clubId"]), {}).get("name"),
|
|
21
|
+
"appearances": entry.get("gamesPlayed"),
|
|
22
|
+
"goals": entry.get("goalsScored"),
|
|
23
|
+
"shirtNumber": entry.get("shirtNumber"),
|
|
24
|
+
"isCaptain": entry.get("isCaptain"),
|
|
25
|
+
"debut": entry.get("debut"),
|
|
26
|
+
"lastMatch": entry.get("lastGame"),
|
|
27
|
+
"status": CAREER_STATUS.get(entry.get("careerState")),
|
|
28
|
+
}
|
|
29
|
+
for entry in history
|
|
30
|
+
],
|
|
31
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
5
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
6
|
+
from transfermarkt_api.tfmkt.reference import (
|
|
7
|
+
RETIRED_CLUB_ID,
|
|
8
|
+
current_assignment,
|
|
9
|
+
date_of_birth,
|
|
10
|
+
full_url,
|
|
11
|
+
get_reference,
|
|
12
|
+
height_cm,
|
|
13
|
+
market_value_of,
|
|
14
|
+
nationality_ids,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def get_player_profile(tfmkt: TfmktClient, player_id: str) -> dict:
|
|
19
|
+
"""
|
|
20
|
+
Player profile.
|
|
21
|
+
|
|
22
|
+
A player is retired when assigned to Transfermarkt's special "Retired" club (ID 123); `retiredSince` is the start
|
|
23
|
+
of that assignment and `club.lastClubId` names the club before it.
|
|
24
|
+
"""
|
|
25
|
+
player, reference = await gather(tfmkt.player(player_id), get_reference(tfmkt))
|
|
26
|
+
attributes = player.get("attributes") or {}
|
|
27
|
+
birth = player.get("birthPlaceDetails") or {}
|
|
28
|
+
assignment = current_assignment(player) or {}
|
|
29
|
+
club_id = str(assignment["clubId"]) if assignment.get("clubId") else None
|
|
30
|
+
is_retired = club_id == RETIRED_CLUB_ID
|
|
31
|
+
last_club_id = str(player["lastClubId"]) if player.get("lastClubId") else None
|
|
32
|
+
national = next((a for a in player.get("clubAssignments") or [] if a.get("type") == "nationalTeam"), None)
|
|
33
|
+
national_id = str(national["clubId"]) if national and national.get("clubId") else None
|
|
34
|
+
|
|
35
|
+
clubs = await tfmkt.clubs(i for i in (club_id, last_club_id, national_id) if i)
|
|
36
|
+
agency = attributes.get("consultantAgency") or {}
|
|
37
|
+
life = player.get("lifeDates") or {}
|
|
38
|
+
values = player.get("marketValueDetails") or {}
|
|
39
|
+
trend = ((values.get("delta") or {}).get("type") or "").lower()
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"id": player_id,
|
|
43
|
+
"url": full_url(player.get("relativeUrl")),
|
|
44
|
+
"name": player.get("name"),
|
|
45
|
+
"fullName": player.get("displayName") or None,
|
|
46
|
+
"nameInHomeCountry": (player.get("nationalityDetails") or {}).get("passportName") or None,
|
|
47
|
+
"imageUrl": player.get("portraitUrl"),
|
|
48
|
+
"dateOfBirth": date_of_birth(player),
|
|
49
|
+
"dateOfDeath": None if life.get("isDateOfDeathUnknown") else life.get("dateOfDeath"),
|
|
50
|
+
"placeOfBirth": {
|
|
51
|
+
"city": birth.get("placeOfBirth") or None,
|
|
52
|
+
"country": reference.country_name(birth.get("countryOfBirthId")),
|
|
53
|
+
},
|
|
54
|
+
"age": life.get("age"),
|
|
55
|
+
"height": height_cm(player),
|
|
56
|
+
"citizenship": reference.country_names(*nationality_ids(player)),
|
|
57
|
+
"isRetired": is_retired,
|
|
58
|
+
"retiredSince": assignment.get("start") if is_retired else None,
|
|
59
|
+
"position": {
|
|
60
|
+
"main": (attributes.get("position") or {}).get("name"),
|
|
61
|
+
"group": attributes.get("positionGroupName"),
|
|
62
|
+
"other": [
|
|
63
|
+
position["name"]
|
|
64
|
+
for position in (attributes.get("firstSidePosition"), attributes.get("secondSidePosition"))
|
|
65
|
+
if position
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
"foot": (attributes.get("preferredFoot") or {}).get("name"),
|
|
69
|
+
"shirtNumber": shirt_number_label(assignment.get("shirtNumber")),
|
|
70
|
+
"club": {
|
|
71
|
+
"id": club_id,
|
|
72
|
+
"name": clubs.get(club_id or "", {}).get("name"),
|
|
73
|
+
"joined": assignment.get("start"),
|
|
74
|
+
"isCaptain": assignment.get("isCaptain"),
|
|
75
|
+
"contractExpires": attributes.get("contractUntil"),
|
|
76
|
+
"lastContractRenewal": date_from_parts(attributes.get("lastContractRenewal")),
|
|
77
|
+
"contractOption": reference.contract_option_name(attributes.get("contractOptionId")),
|
|
78
|
+
"lastClubId": last_club_id,
|
|
79
|
+
"lastClubName": clubs.get(last_club_id or "", {}).get("name"),
|
|
80
|
+
},
|
|
81
|
+
"nationalTeam": {
|
|
82
|
+
"id": national_id,
|
|
83
|
+
"name": clubs.get(national_id, {}).get("name"),
|
|
84
|
+
"shirtNumber": shirt_number_label(national.get("shirtNumber")),
|
|
85
|
+
"isCaptain": national.get("isCaptain"),
|
|
86
|
+
"debut": national.get("debut"),
|
|
87
|
+
}
|
|
88
|
+
if national_id and national
|
|
89
|
+
else None,
|
|
90
|
+
"marketValue": market_value_of(values.get("current")),
|
|
91
|
+
"marketValueDetails": {
|
|
92
|
+
"lastUpdated": (values.get("current") or {}).get("determined"),
|
|
93
|
+
"trend": trend if trend in ("increased", "decreased", "unchanged") else None,
|
|
94
|
+
"previous": dated_value(values.get("previous")),
|
|
95
|
+
"highest": dated_value(values.get("highest")),
|
|
96
|
+
}
|
|
97
|
+
if values
|
|
98
|
+
else None,
|
|
99
|
+
"agent": {"name": agency.get("name"), "url": agency.get("relativeUrl")} if agency else None,
|
|
100
|
+
"outfitter": (attributes.get("outfitter") or {}).get("name"),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def shirt_number_label(number: Optional[int]) -> Optional[str]:
|
|
105
|
+
"""Shirt number as the website shows it, e.g. "#10"."""
|
|
106
|
+
return f"#{number}" if number is not None else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def dated_value(money: Optional[dict]) -> Optional[dict]:
|
|
110
|
+
"""`{value, date}` for a market value with the date it was set, or None when there is no valuation."""
|
|
111
|
+
value = market_value_of(money)
|
|
112
|
+
return {"value": value, "date": money.get("determined")} if value and money else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def date_from_parts(parts: Optional[dict]) -> Optional[str]:
|
|
116
|
+
"""ISO date from upstream `{year, month, day}`, or None when incomplete or invalid."""
|
|
117
|
+
if parts is None:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
return date(parts["year"], parts["month"], parts["day"]).isoformat()
|
|
121
|
+
except (TypeError, KeyError, ValueError):
|
|
122
|
+
return None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
2
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
3
|
+
from transfermarkt_api.tfmkt.reference import (
|
|
4
|
+
current_assignment,
|
|
5
|
+
get_reference,
|
|
6
|
+
last_page_number,
|
|
7
|
+
market_value_of,
|
|
8
|
+
nationality_ids,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def search_players(tfmkt: TfmktClient, query: str, page_number: int) -> dict:
|
|
13
|
+
"""Search players by name. Results keep the upstream search ranking."""
|
|
14
|
+
search = await tfmkt.quick_search(query, page_number)
|
|
15
|
+
player_ids = [str(i) for i in search["result"]["playerIds"]]
|
|
16
|
+
|
|
17
|
+
players, reference = await gather(tfmkt.players(player_ids), get_reference(tfmkt))
|
|
18
|
+
assignments = {i: current_assignment(players.get(i, {})) for i in player_ids}
|
|
19
|
+
clubs = await tfmkt.clubs(a["clubId"] for a in assignments.values() if a)
|
|
20
|
+
|
|
21
|
+
results = []
|
|
22
|
+
for player_id in player_ids:
|
|
23
|
+
player = players.get(player_id, {})
|
|
24
|
+
attributes = player.get("attributes") or {}
|
|
25
|
+
assignment = assignments[player_id]
|
|
26
|
+
club_id = str(assignment["clubId"]) if assignment else None
|
|
27
|
+
results.append(
|
|
28
|
+
{
|
|
29
|
+
"id": player_id,
|
|
30
|
+
"name": player.get("name"),
|
|
31
|
+
"position": (attributes.get("position") or {}).get("shortName"),
|
|
32
|
+
"club": {"id": club_id, "name": clubs.get(club_id, {}).get("name")} if club_id else None,
|
|
33
|
+
"age": (player.get("lifeDates") or {}).get("age"),
|
|
34
|
+
"nationalities": reference.country_names(*nationality_ids(player)),
|
|
35
|
+
"marketValue": market_value_of((player.get("marketValueDetails") or {}).get("current")),
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
"query": query,
|
|
41
|
+
"pageNumber": page_number,
|
|
42
|
+
"lastPageNumber": last_page_number(search["totalCount"]["players"]),
|
|
43
|
+
"results": results,
|
|
44
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
|
|
3
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
4
|
+
|
|
5
|
+
# Competition type IDs the website's detailed stats table leaves out (national-team matches, including friendlies).
|
|
6
|
+
# Mirrors `nationalTeamCompetitions` in the website's player-performance-proxy bundle.
|
|
7
|
+
NATIONAL_TEAM_COMPETITION_TYPE_IDS = {11, 17, 19, 20}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def get_player_stats(tfmkt: TfmktClient, player_id: str) -> dict:
|
|
11
|
+
"""
|
|
12
|
+
Aggregate a player's match records into per season, competition and club totals.
|
|
13
|
+
|
|
14
|
+
Follows the website's own aggregation of `/player/{id}/performance-game`: appearances count matches with
|
|
15
|
+
participation state `played`, numeric statistics are summed, and each card event counts once. The goalkeeper
|
|
16
|
+
columns follow the website too: `goalsConceded` sums the opponent goals scored while the player was on the pitch,
|
|
17
|
+
and `cleanSheets` counts matches the player played in which the opponent did not score at all. Both are computed
|
|
18
|
+
for every player; the website shows them only for goalkeepers.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
dict: The player ID and one stats entry per (season, competition, club), most recent season first.
|
|
22
|
+
"""
|
|
23
|
+
performance = (await tfmkt.player_performance_games(player_id))["performance"]
|
|
24
|
+
games = [
|
|
25
|
+
game
|
|
26
|
+
for game in performance
|
|
27
|
+
if game["gameInformation"]["competitionTypeId"] not in NATIONAL_TEAM_COMPETITION_TYPE_IDS
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
totals: dict[tuple[str, str, str], Counter] = {}
|
|
31
|
+
for game in games:
|
|
32
|
+
info = game["gameInformation"]
|
|
33
|
+
key = (str(info["seasonId"]), info["competitionId"], str(game["clubsInformation"]["club"]["clubId"]))
|
|
34
|
+
total = totals.setdefault(key, Counter())
|
|
35
|
+
stats = game["statistics"]
|
|
36
|
+
if stats["generalStatistics"]["participationState"] != "played":
|
|
37
|
+
continue
|
|
38
|
+
cards = stats["cardStatistics"]
|
|
39
|
+
total["appearances"] += 1
|
|
40
|
+
total["goals"] += stats["goalStatistics"]["goalsScoredTotal"] or 0
|
|
41
|
+
total["assists"] += stats["goalStatistics"]["assists"] or 0
|
|
42
|
+
total["yellowCards"] += 1 if cards.get("yellowCard") else 0
|
|
43
|
+
total["secondYellowCards"] += 1 if cards.get("yellowRedCard") else 0
|
|
44
|
+
total["redCards"] += 1 if cards.get("redCard") else 0
|
|
45
|
+
played_minutes = stats["playingTimeStatistics"]["playedMinutes"] or 0
|
|
46
|
+
total["minutesPlayed"] += played_minutes
|
|
47
|
+
total["goalsConceded"] += stats["goalStatistics"]["opponentGoalsOnThePitch"] or 0
|
|
48
|
+
total["cleanSheets"] += (
|
|
49
|
+
1 if played_minutes and game["clubsInformation"]["club"]["opponentGoalsTotal"] == 0 else 0
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
competitions = await tfmkt.competitions(competition_id for _, competition_id, _ in totals)
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"id": player_id,
|
|
56
|
+
"stats": [
|
|
57
|
+
{
|
|
58
|
+
"competitionId": competition_id,
|
|
59
|
+
"competitionName": competitions.get(competition_id, {}).get("name"),
|
|
60
|
+
"seasonId": season_id,
|
|
61
|
+
"clubId": club_id,
|
|
62
|
+
"appearances": total["appearances"],
|
|
63
|
+
"goals": total["goals"],
|
|
64
|
+
"assists": total["assists"],
|
|
65
|
+
"yellowCards": total["yellowCards"],
|
|
66
|
+
"secondYellowCards": total["secondYellowCards"],
|
|
67
|
+
"redCards": total["redCards"],
|
|
68
|
+
"minutesPlayed": total["minutesPlayed"],
|
|
69
|
+
"goalsConceded": total["goalsConceded"],
|
|
70
|
+
"cleanSheets": total["cleanSheets"],
|
|
71
|
+
}
|
|
72
|
+
for (season_id, competition_id, club_id), total in totals.items()
|
|
73
|
+
],
|
|
74
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from transfermarkt_api.tfmkt import TfmktClient
|
|
5
|
+
from transfermarkt_api.tfmkt.concurrency import gather
|
|
6
|
+
from transfermarkt_api.tfmkt.reference import get_reference, market_value_of, value_of
|
|
7
|
+
|
|
8
|
+
# upstream typeDetails.type -> transferType; STANDARD is split into transfer and freeTransfer by its fee label
|
|
9
|
+
TRANSFER_TYPES = {
|
|
10
|
+
"STANDARD": "transfer",
|
|
11
|
+
"INTERNAL_TRANSFER": "internal",
|
|
12
|
+
"ACTIVE_LOAN_TRANSFER": "loan",
|
|
13
|
+
"RETURNED_FROM_PREVIOUS_LOAN": "endOfLoan",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# formerClubsNote lists youth clubs as "Grandoli FC (1992-1995), Newell's Old Boys (1995-2000)"
|
|
17
|
+
YOUTH_CLUB_SEPARATOR = re.compile(r"(?<=\)),\s*")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def get_player_transfers(tfmkt: TfmktClient, player_id: str) -> dict:
|
|
21
|
+
"""
|
|
22
|
+
Transfer history, upcoming (pending) transfers first, then completed ones, most recent first.
|
|
23
|
+
|
|
24
|
+
`fee` is null when upstream has no numeric fee: free transfers, loans without fee and unknown fees (`-`).
|
|
25
|
+
`transferType` tells these apart: an unknown fee is a `transfer` with a null fee, never a `freeTransfer`.
|
|
26
|
+
"""
|
|
27
|
+
player, history, reference = await gather(
|
|
28
|
+
tfmkt.player(player_id),
|
|
29
|
+
tfmkt.player_transfer_history(player_id),
|
|
30
|
+
get_reference(tfmkt),
|
|
31
|
+
)
|
|
32
|
+
transfers = (history or {}).get("history") or {}
|
|
33
|
+
records = (transfers.get("pending") or []) + (transfers.get("terminated") or [])
|
|
34
|
+
sides = [side for record in records for side in (record["transferSource"], record["transferDestination"])]
|
|
35
|
+
clubs, competitions = await gather(
|
|
36
|
+
tfmkt.clubs(side["clubId"] for side in sides),
|
|
37
|
+
tfmkt.competitions(side.get("competitionId") for side in sides),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def club(side: dict) -> dict:
|
|
41
|
+
"""Club with the league and country recorded at the time of this transfer."""
|
|
42
|
+
club_id = str(side["clubId"])
|
|
43
|
+
country_id = side.get("countryId")
|
|
44
|
+
competition_id = side.get("competitionId")
|
|
45
|
+
return {
|
|
46
|
+
"id": club_id,
|
|
47
|
+
"name": clubs.get(club_id, {}).get("name"),
|
|
48
|
+
"countryId": str(country_id) if country_id is not None else None,
|
|
49
|
+
"countryName": reference.country_name(country_id),
|
|
50
|
+
"league": {
|
|
51
|
+
"id": str(competition_id),
|
|
52
|
+
"name": competitions.get(str(competition_id), {}).get("name"),
|
|
53
|
+
}
|
|
54
|
+
if competition_id
|
|
55
|
+
else None,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
former_clubs_note = (player.get("attributes") or {}).get("formerClubsNote")
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"id": player_id,
|
|
62
|
+
"transfers": [
|
|
63
|
+
{
|
|
64
|
+
"id": str(record["id"]),
|
|
65
|
+
"clubFrom": club(record["transferSource"]),
|
|
66
|
+
"clubTo": club(record["transferDestination"]),
|
|
67
|
+
"date": (record["details"].get("date") or "")[:10] or None,
|
|
68
|
+
"upcoming": bool(record["details"].get("isPending")),
|
|
69
|
+
"transferType": transfer_type(record),
|
|
70
|
+
"season": (record["details"].get("season") or {}).get("display"),
|
|
71
|
+
"marketValue": market_value_of(record["details"].get("marketValue")),
|
|
72
|
+
"fee": value_of(record["details"].get("fee")),
|
|
73
|
+
"age": record["details"].get("age"),
|
|
74
|
+
"contractUntil": (record["details"].get("contractUntilDate") or "")[:10] or None,
|
|
75
|
+
"remainingContractDays": (record["details"].get("remainingContractPeriod") or {}).get("days"),
|
|
76
|
+
}
|
|
77
|
+
for record in records
|
|
78
|
+
],
|
|
79
|
+
"youthClubs": YOUTH_CLUB_SEPARATOR.split(former_clubs_note) if former_clubs_note else [],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def transfer_type(record: dict) -> Optional[str]:
|
|
84
|
+
"""Kind of transfer, or None for an upstream type this API does not know."""
|
|
85
|
+
kind = TRANSFER_TYPES.get((record.get("typeDetails") or {}).get("type") or "")
|
|
86
|
+
fee_label = ((record["details"].get("fee") or {}).get("compact") or {}).get("content") or ""
|
|
87
|
+
if kind == "transfer" and fee_label.strip().lower() == "free transfer":
|
|
88
|
+
return "freeTransfer"
|
|
89
|
+
return kind
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from transfermarkt_api.tfmkt.client import TfmktClient as TfmktClient
|