tacoscore 0.1.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.
- tacoscore/__init__.py +149 -0
- tacoscore/_serialize.py +61 -0
- tacoscore/async_client.py +411 -0
- tacoscore/async_http.py +5 -0
- tacoscore/async_rate_limiter.py +64 -0
- tacoscore/cli.py +583 -0
- tacoscore/client.py +452 -0
- tacoscore/coordinates.py +112 -0
- tacoscore/dataframe.py +315 -0
- tacoscore/exceptions.py +38 -0
- tacoscore/extraction.py +131 -0
- tacoscore/help_catalog.py +580 -0
- tacoscore/http.py +262 -0
- tacoscore/interactive_help.py +305 -0
- tacoscore/models/__init__.py +5 -0
- tacoscore/models/actions.py +86 -0
- tacoscore/models/common.py +48 -0
- tacoscore/models/event.py +54 -0
- tacoscore/models/graph.py +30 -0
- tacoscore/models/h2h.py +24 -0
- tacoscore/models/heatmap.py +52 -0
- tacoscore/models/incidents.py +55 -0
- tacoscore/models/lineups.py +85 -0
- tacoscore/models/match_detail.py +69 -0
- tacoscore/models/player.py +53 -0
- tacoscore/models/pregame_form.py +26 -0
- tacoscore/models/profile.py +48 -0
- tacoscore/models/shotmap.py +134 -0
- tacoscore/models/standings.py +51 -0
- tacoscore/models/statistics.py +186 -0
- tacoscore/models/team.py +29 -0
- tacoscore/models/team_rankings.py +32 -0
- tacoscore/models/tournament.py +230 -0
- tacoscore/models/transfers.py +31 -0
- tacoscore/parsers/__init__.py +59 -0
- tacoscore/parsers/actions.py +54 -0
- tacoscore/parsers/common.py +84 -0
- tacoscore/parsers/graph.py +24 -0
- tacoscore/parsers/h2h.py +30 -0
- tacoscore/parsers/heatmap.py +21 -0
- tacoscore/parsers/incidents.py +50 -0
- tacoscore/parsers/lineups.py +48 -0
- tacoscore/parsers/match_detail.py +82 -0
- tacoscore/parsers/pregame_form.py +42 -0
- tacoscore/parsers/profile.py +60 -0
- tacoscore/parsers/shotmap.py +69 -0
- tacoscore/parsers/standings.py +58 -0
- tacoscore/parsers/statistics.py +181 -0
- tacoscore/parsers/team_rankings.py +34 -0
- tacoscore/parsers/tournament.py +120 -0
- tacoscore/parsers/transfers.py +51 -0
- tacoscore/py.typed +0 -0
- tacoscore/rate_limiter.py +57 -0
- tacoscore-0.1.0.dist-info/METADATA +973 -0
- tacoscore-0.1.0.dist-info/RECORD +58 -0
- tacoscore-0.1.0.dist-info/WHEEL +4 -0
- tacoscore-0.1.0.dist-info/entry_points.txt +2 -0
- tacoscore-0.1.0.dist-info/licenses/LICENSE +21 -0
tacoscore/__init__.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""TacosScore — football analytics from Sofascore.
|
|
2
|
+
|
|
3
|
+
Public API surface — import from here for everything you'll typically need.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from tacoscore.async_client import AsyncSofascoreClient
|
|
7
|
+
from tacoscore.client import SofascoreClient
|
|
8
|
+
from tacoscore.extraction import (
|
|
9
|
+
ANALYTICS_EVENT_ENDPOINTS,
|
|
10
|
+
MATCH_EXTRACTION_ORDER,
|
|
11
|
+
strip_ui_fields,
|
|
12
|
+
)
|
|
13
|
+
from tacoscore.help_catalog import format_help_entry, get_help_entry, list_help_entries
|
|
14
|
+
from tacoscore.interactive_help import show_help
|
|
15
|
+
from tacoscore.dataframe import (
|
|
16
|
+
actions_to_dataframe,
|
|
17
|
+
graph_to_dataframe,
|
|
18
|
+
heatmap_to_dataframe,
|
|
19
|
+
incidents_to_dataframe,
|
|
20
|
+
lineups_to_dataframe,
|
|
21
|
+
matches_to_dataframe,
|
|
22
|
+
player_stats_to_dataframe,
|
|
23
|
+
rounds_to_dataframe,
|
|
24
|
+
season_player_stats_to_dataframe,
|
|
25
|
+
shotmap_to_dataframe,
|
|
26
|
+
standings_to_dataframe,
|
|
27
|
+
team_stats_to_dataframe,
|
|
28
|
+
)
|
|
29
|
+
from tacoscore.exceptions import (
|
|
30
|
+
APIError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
ParseError,
|
|
33
|
+
RateLimitError,
|
|
34
|
+
SofascoreError,
|
|
35
|
+
)
|
|
36
|
+
from tacoscore.models.actions import Action, ActionStream
|
|
37
|
+
from tacoscore.models.common import Country, Sport
|
|
38
|
+
from tacoscore.models.event import FullMatch, PlayerMatchData
|
|
39
|
+
from tacoscore.models.graph import GraphPoint, MatchGraph
|
|
40
|
+
from tacoscore.models.h2h import DuelRecord, HeadToHead
|
|
41
|
+
from tacoscore.models.heatmap import Heatmap, HeatmapPoint
|
|
42
|
+
from tacoscore.models.incidents import Incident, Incidents
|
|
43
|
+
from tacoscore.models.lineups import LineupEntry, Lineups, LineupSide
|
|
44
|
+
from tacoscore.models.match_detail import EventDetail, EventManagers, Manager, Referee, Venue
|
|
45
|
+
from tacoscore.models.player import Player
|
|
46
|
+
from tacoscore.models.pregame_form import PregameForm, TeamFormSnapshot
|
|
47
|
+
from tacoscore.models.profile import PlayerProfile, SeasonPlayerStatistics, TeamProfile
|
|
48
|
+
from tacoscore.models.shotmap import Shot, Shotmap
|
|
49
|
+
from tacoscore.models.standings import StandingRow, Standings, StandingsTable
|
|
50
|
+
from tacoscore.models.statistics import (
|
|
51
|
+
PlayerStats,
|
|
52
|
+
SinglePlayerStats,
|
|
53
|
+
TeamStatistics,
|
|
54
|
+
TeamStatValue,
|
|
55
|
+
)
|
|
56
|
+
from tacoscore.models.team import Team
|
|
57
|
+
from tacoscore.models.team_rankings import TeamRankingEntry, TeamRankings
|
|
58
|
+
from tacoscore.models.transfers import Transfer, TransferHistory
|
|
59
|
+
from tacoscore.models.tournament import (
|
|
60
|
+
MatchList,
|
|
61
|
+
MatchScore,
|
|
62
|
+
MatchSummary,
|
|
63
|
+
Round,
|
|
64
|
+
RoundList,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
__version__ = "0.1.0"
|
|
68
|
+
|
|
69
|
+
__all__ = [ # noqa: RUF022 -- grouped by category for readability
|
|
70
|
+
# Client
|
|
71
|
+
"SofascoreClient",
|
|
72
|
+
"AsyncSofascoreClient",
|
|
73
|
+
"ANALYTICS_EVENT_ENDPOINTS",
|
|
74
|
+
"MATCH_EXTRACTION_ORDER",
|
|
75
|
+
"strip_ui_fields",
|
|
76
|
+
"format_help_entry",
|
|
77
|
+
"get_help_entry",
|
|
78
|
+
"list_help_entries",
|
|
79
|
+
"show_help",
|
|
80
|
+
# Exceptions
|
|
81
|
+
"SofascoreError",
|
|
82
|
+
"APIError",
|
|
83
|
+
"RateLimitError",
|
|
84
|
+
"NotFoundError",
|
|
85
|
+
"ParseError",
|
|
86
|
+
# Models
|
|
87
|
+
"Action",
|
|
88
|
+
"ActionStream",
|
|
89
|
+
"Country",
|
|
90
|
+
"DuelRecord",
|
|
91
|
+
"EventDetail",
|
|
92
|
+
"EventManagers",
|
|
93
|
+
"FullMatch",
|
|
94
|
+
"GraphPoint",
|
|
95
|
+
"HeadToHead",
|
|
96
|
+
"Heatmap",
|
|
97
|
+
"HeatmapPoint",
|
|
98
|
+
"Incident",
|
|
99
|
+
"Incidents",
|
|
100
|
+
"LineupEntry",
|
|
101
|
+
"Lineups",
|
|
102
|
+
"LineupSide",
|
|
103
|
+
"Manager",
|
|
104
|
+
"MatchGraph",
|
|
105
|
+
"MatchList",
|
|
106
|
+
"MatchScore",
|
|
107
|
+
"MatchSummary",
|
|
108
|
+
"Player",
|
|
109
|
+
"PlayerMatchData",
|
|
110
|
+
"PregameForm",
|
|
111
|
+
"PlayerProfile",
|
|
112
|
+
"PlayerStats",
|
|
113
|
+
"Referee",
|
|
114
|
+
"Round",
|
|
115
|
+
"RoundList",
|
|
116
|
+
"SeasonPlayerStatistics",
|
|
117
|
+
"Shot",
|
|
118
|
+
"Shotmap",
|
|
119
|
+
"SinglePlayerStats",
|
|
120
|
+
"Sport",
|
|
121
|
+
"StandingRow",
|
|
122
|
+
"Standings",
|
|
123
|
+
"StandingsTable",
|
|
124
|
+
"Team",
|
|
125
|
+
"TeamFormSnapshot",
|
|
126
|
+
"TeamProfile",
|
|
127
|
+
"TeamRankingEntry",
|
|
128
|
+
"TeamRankings",
|
|
129
|
+
"Transfer",
|
|
130
|
+
"TransferHistory",
|
|
131
|
+
"TeamStatistics",
|
|
132
|
+
"TeamStatValue",
|
|
133
|
+
"Venue",
|
|
134
|
+
# DataFrame export (requires pandas)
|
|
135
|
+
"actions_to_dataframe",
|
|
136
|
+
"graph_to_dataframe",
|
|
137
|
+
"heatmap_to_dataframe",
|
|
138
|
+
"incidents_to_dataframe",
|
|
139
|
+
"lineups_to_dataframe",
|
|
140
|
+
"matches_to_dataframe",
|
|
141
|
+
"player_stats_to_dataframe",
|
|
142
|
+
"rounds_to_dataframe",
|
|
143
|
+
"season_player_stats_to_dataframe",
|
|
144
|
+
"shotmap_to_dataframe",
|
|
145
|
+
"standings_to_dataframe",
|
|
146
|
+
"team_stats_to_dataframe",
|
|
147
|
+
# Meta
|
|
148
|
+
"__version__",
|
|
149
|
+
]
|
tacoscore/_serialize.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Internal: convert dataclass models to JSON-compatible dicts.
|
|
2
|
+
|
|
3
|
+
Used by the CLI to serialize parsed models. The ``raw`` field on each model
|
|
4
|
+
is excluded by default to give clean output; pass ``include_raw=True`` to
|
|
5
|
+
preserve the original API payload nested under each model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from tacoscore.extraction import strip_ui_fields
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_jsonable(
|
|
18
|
+
obj: Any,
|
|
19
|
+
include_raw: bool = False,
|
|
20
|
+
*,
|
|
21
|
+
analytics_only: bool = False,
|
|
22
|
+
) -> Any:
|
|
23
|
+
"""Recursively convert ``obj`` into JSON-serializable primitives.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
obj: A dataclass, dict, list, tuple, date/datetime, or primitive.
|
|
27
|
+
include_raw: If False (default), strip ``raw`` fields from any
|
|
28
|
+
dataclass to give a clean typed view. If True, keep them.
|
|
29
|
+
analytics_only: If True, strip UI-only Sofascore keys per
|
|
30
|
+
``docs/sofascore-api-reference.md`` (DROP fields).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A structure that ``json.dumps`` can serialize directly.
|
|
34
|
+
"""
|
|
35
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
36
|
+
result: dict[str, Any] = {}
|
|
37
|
+
for f in dataclasses.fields(obj):
|
|
38
|
+
if not include_raw and f.name == "raw":
|
|
39
|
+
continue
|
|
40
|
+
result[f.name] = to_jsonable(
|
|
41
|
+
getattr(obj, f.name), include_raw, analytics_only=analytics_only
|
|
42
|
+
)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
if isinstance(obj, dict):
|
|
46
|
+
cleaned = {
|
|
47
|
+
str(k): to_jsonable(v, include_raw, analytics_only=analytics_only)
|
|
48
|
+
for k, v in obj.items()
|
|
49
|
+
}
|
|
50
|
+
return strip_ui_fields(cleaned) if analytics_only else cleaned
|
|
51
|
+
|
|
52
|
+
if isinstance(obj, (list, tuple)):
|
|
53
|
+
items = [
|
|
54
|
+
to_jsonable(x, include_raw, analytics_only=analytics_only) for x in obj
|
|
55
|
+
]
|
|
56
|
+
return strip_ui_fields(items) if analytics_only else items
|
|
57
|
+
|
|
58
|
+
if isinstance(obj, (date, datetime)):
|
|
59
|
+
return obj.isoformat()
|
|
60
|
+
|
|
61
|
+
return obj
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Async client for the Sofascore API.
|
|
2
|
+
|
|
3
|
+
Mirror of :class:`tacoscore.client.SofascoreClient` with ``async def``
|
|
4
|
+
methods and an ``httpx.AsyncClient`` under the hood. Use this when you need
|
|
5
|
+
to integrate with an existing asyncio application (FastAPI, Discord bots,
|
|
6
|
+
background jobs).
|
|
7
|
+
|
|
8
|
+
Important:
|
|
9
|
+
Async **does not make scraping faster** — the rate limiter is the
|
|
10
|
+
bottleneck and it's shared across all concurrent calls. The benefit is
|
|
11
|
+
that *other coroutines* can do work while waiting for the next request
|
|
12
|
+
slot, which matters in event-driven applications but not in a simple
|
|
13
|
+
extraction script. For batch extraction, the sync client is just as
|
|
14
|
+
fast and simpler.
|
|
15
|
+
|
|
16
|
+
Example::
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from tacoscore import AsyncSofascoreClient
|
|
20
|
+
|
|
21
|
+
async def main():
|
|
22
|
+
async with AsyncSofascoreClient() as client:
|
|
23
|
+
stats = await client.event_statistics(15186861)
|
|
24
|
+
lineups = await client.event_lineups(15186861)
|
|
25
|
+
print(f"{lineups.home.formation} vs {lineups.away.formation}")
|
|
26
|
+
|
|
27
|
+
asyncio.run(main())
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import contextlib
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from tacoscore.async_http import AsyncHttpTransport
|
|
37
|
+
from tacoscore.async_rate_limiter import AsyncRateLimiter
|
|
38
|
+
from tacoscore.client import DEFAULT_BASE_URL
|
|
39
|
+
from tacoscore.extraction import (
|
|
40
|
+
should_fetch_heatmap,
|
|
41
|
+
should_fetch_shotmap,
|
|
42
|
+
should_fetch_spatial,
|
|
43
|
+
)
|
|
44
|
+
from tacoscore.exceptions import NotFoundError
|
|
45
|
+
from tacoscore.models.actions import ActionStream
|
|
46
|
+
from tacoscore.models.event import FullMatch, PlayerMatchData
|
|
47
|
+
from tacoscore.models.graph import MatchGraph
|
|
48
|
+
from tacoscore.models.h2h import HeadToHead
|
|
49
|
+
from tacoscore.models.heatmap import Heatmap
|
|
50
|
+
from tacoscore.models.incidents import Incidents
|
|
51
|
+
from tacoscore.models.lineups import Lineups
|
|
52
|
+
from tacoscore.models.match_detail import EventDetail, EventManagers
|
|
53
|
+
from tacoscore.models.pregame_form import PregameForm
|
|
54
|
+
from tacoscore.models.profile import PlayerProfile, SeasonPlayerStatistics, TeamProfile
|
|
55
|
+
from tacoscore.models.shotmap import Shotmap
|
|
56
|
+
from tacoscore.models.standings import Standings
|
|
57
|
+
from tacoscore.models.statistics import SinglePlayerStats, TeamStatistics
|
|
58
|
+
from tacoscore.models.team_rankings import TeamRankings
|
|
59
|
+
from tacoscore.models.tournament import MatchList, RoundList
|
|
60
|
+
from tacoscore.models.transfers import TransferHistory
|
|
61
|
+
from tacoscore.parsers import (
|
|
62
|
+
parse_action_stream,
|
|
63
|
+
parse_event_detail,
|
|
64
|
+
parse_event_managers,
|
|
65
|
+
parse_head_to_head,
|
|
66
|
+
parse_heatmap,
|
|
67
|
+
parse_incidents,
|
|
68
|
+
parse_lineups,
|
|
69
|
+
parse_match_graph,
|
|
70
|
+
parse_match_list,
|
|
71
|
+
parse_player_profile,
|
|
72
|
+
parse_pregame_form,
|
|
73
|
+
parse_round_list,
|
|
74
|
+
parse_season_player_statistics,
|
|
75
|
+
parse_shotmap,
|
|
76
|
+
parse_single_player_stats,
|
|
77
|
+
parse_standings,
|
|
78
|
+
parse_team_profile,
|
|
79
|
+
parse_team_rankings,
|
|
80
|
+
parse_team_statistics,
|
|
81
|
+
parse_transfer_history,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AsyncSofascoreClient:
|
|
86
|
+
"""Async client for the Sofascore football API.
|
|
87
|
+
|
|
88
|
+
Construction options match :class:`SofascoreClient`. Use as an async
|
|
89
|
+
context manager so the connection pool closes cleanly.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
>>> async with AsyncSofascoreClient(rate_limit_seconds=2.0) as client:
|
|
93
|
+
... heatmap = await client.player_heatmap(15186861, 868812)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
99
|
+
rate_limit_seconds: float = 1.5,
|
|
100
|
+
rate_jitter_seconds: float = 0.5,
|
|
101
|
+
timeout: float = 30.0,
|
|
102
|
+
max_retries: int = 3,
|
|
103
|
+
user_agent: str | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
self._rate_limiter = AsyncRateLimiter(
|
|
106
|
+
min_interval=rate_limit_seconds,
|
|
107
|
+
jitter=rate_jitter_seconds,
|
|
108
|
+
)
|
|
109
|
+
self._http = AsyncHttpTransport(
|
|
110
|
+
base_url=base_url,
|
|
111
|
+
rate_limiter=self._rate_limiter,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
max_retries=max_retries,
|
|
114
|
+
user_agent=user_agent,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# ----- low-level access -----
|
|
118
|
+
|
|
119
|
+
async def get_raw(self, path: str) -> dict[str, Any]:
|
|
120
|
+
"""Issue a GET against the API and return the raw JSON dict."""
|
|
121
|
+
return await self._http.get(path)
|
|
122
|
+
|
|
123
|
+
# ----- match-level endpoints -----
|
|
124
|
+
|
|
125
|
+
async def event(self, event_id: int) -> EventDetail:
|
|
126
|
+
"""Match metadata — teams, score, venue, referee, attendance."""
|
|
127
|
+
raw = await self._http.get(f"event/{event_id}")
|
|
128
|
+
return parse_event_detail(raw)
|
|
129
|
+
|
|
130
|
+
async def event_incidents(self, event_id: int) -> Incidents:
|
|
131
|
+
"""Match timeline — goals, cards, substitutions, period markers."""
|
|
132
|
+
raw = await self._http.get(f"event/{event_id}/incidents")
|
|
133
|
+
return parse_incidents(raw)
|
|
134
|
+
|
|
135
|
+
async def event_graph(self, event_id: int) -> MatchGraph:
|
|
136
|
+
"""Attack momentum graph (minute-by-minute pressure values)."""
|
|
137
|
+
raw = await self._http.get(f"event/{event_id}/graph")
|
|
138
|
+
return parse_match_graph(raw)
|
|
139
|
+
|
|
140
|
+
async def event_managers(self, event_id: int) -> EventManagers:
|
|
141
|
+
"""Managers for both teams in a match."""
|
|
142
|
+
raw = await self._http.get(f"event/{event_id}/managers")
|
|
143
|
+
return parse_event_managers(raw)
|
|
144
|
+
|
|
145
|
+
async def event_statistics(self, event_id: int) -> TeamStatistics:
|
|
146
|
+
"""Team statistics by period for one match."""
|
|
147
|
+
raw = await self._http.get(f"event/{event_id}/statistics")
|
|
148
|
+
return parse_team_statistics(raw)
|
|
149
|
+
|
|
150
|
+
async def event_lineups(self, event_id: int) -> Lineups:
|
|
151
|
+
"""Lineups and per-player match statistics."""
|
|
152
|
+
raw = await self._http.get(f"event/{event_id}/lineups")
|
|
153
|
+
return parse_lineups(raw)
|
|
154
|
+
|
|
155
|
+
async def event_shotmap(self, event_id: int) -> Shotmap:
|
|
156
|
+
"""All shots in a match (every player)."""
|
|
157
|
+
raw = await self._http.get(f"event/{event_id}/shotmap")
|
|
158
|
+
return parse_shotmap(raw)
|
|
159
|
+
|
|
160
|
+
async def event_h2h(self, event_id: int) -> HeadToHead:
|
|
161
|
+
"""Head-to-head win/draw record."""
|
|
162
|
+
raw = await self._http.get(f"event/{event_id}/h2h")
|
|
163
|
+
return parse_head_to_head(raw)
|
|
164
|
+
|
|
165
|
+
async def event_pregame_form(self, event_id: int) -> PregameForm:
|
|
166
|
+
"""Pre-match form for both teams."""
|
|
167
|
+
raw = await self._http.get(f"event/{event_id}/pregame-form")
|
|
168
|
+
return parse_pregame_form(raw)
|
|
169
|
+
|
|
170
|
+
# ----- player-level endpoints -----
|
|
171
|
+
|
|
172
|
+
async def player_heatmap(self, event_id: int, player_id: int) -> Heatmap:
|
|
173
|
+
"""Touch heatmap for one player in one match."""
|
|
174
|
+
raw = await self._http.get(f"event/{event_id}/player/{player_id}/heatmap")
|
|
175
|
+
return parse_heatmap(raw)
|
|
176
|
+
|
|
177
|
+
async def player_actions(self, event_id: int, player_id: int) -> ActionStream:
|
|
178
|
+
"""Action stream (passes, dribbles, defensive, ball-carries)."""
|
|
179
|
+
raw = await self._http.get(
|
|
180
|
+
f"event/{event_id}/player/{player_id}/rating-breakdown"
|
|
181
|
+
)
|
|
182
|
+
return parse_action_stream(raw)
|
|
183
|
+
|
|
184
|
+
async def player_shotmap(self, event_id: int, player_id: int) -> Shotmap:
|
|
185
|
+
"""Shot map for one player in one match."""
|
|
186
|
+
raw = await self._http.get(f"event/{event_id}/shotmap/player/{player_id}")
|
|
187
|
+
return parse_shotmap(raw)
|
|
188
|
+
|
|
189
|
+
async def player_statistics(
|
|
190
|
+
self, event_id: int, player_id: int
|
|
191
|
+
) -> SinglePlayerStats:
|
|
192
|
+
"""Single-player match statistics."""
|
|
193
|
+
raw = await self._http.get(f"event/{event_id}/player/{player_id}/statistics")
|
|
194
|
+
return parse_single_player_stats(raw)
|
|
195
|
+
|
|
196
|
+
# ----- tournament discovery -----
|
|
197
|
+
|
|
198
|
+
async def tournament_rounds(
|
|
199
|
+
self, tournament_id: int, season_id: int
|
|
200
|
+
) -> RoundList:
|
|
201
|
+
"""List all rounds in a tournament season."""
|
|
202
|
+
raw = await self._http.get(
|
|
203
|
+
f"unique-tournament/{tournament_id}/season/{season_id}/rounds"
|
|
204
|
+
)
|
|
205
|
+
return parse_round_list(raw)
|
|
206
|
+
|
|
207
|
+
async def round_events(
|
|
208
|
+
self,
|
|
209
|
+
tournament_id: int,
|
|
210
|
+
season_id: int,
|
|
211
|
+
round_number: int,
|
|
212
|
+
slug: str | None = None,
|
|
213
|
+
) -> MatchList:
|
|
214
|
+
"""List matches in a specific round."""
|
|
215
|
+
if slug:
|
|
216
|
+
path = (
|
|
217
|
+
f"unique-tournament/{tournament_id}/season/{season_id}"
|
|
218
|
+
f"/events/round/{round_number}/slug/{slug}"
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
path = (
|
|
222
|
+
f"unique-tournament/{tournament_id}/season/{season_id}"
|
|
223
|
+
f"/events/round/{round_number}"
|
|
224
|
+
)
|
|
225
|
+
raw = await self._http.get(path)
|
|
226
|
+
return parse_match_list(raw)
|
|
227
|
+
|
|
228
|
+
async def season_events(
|
|
229
|
+
self, tournament_id: int, season_id: int
|
|
230
|
+
) -> dict[int, MatchList]:
|
|
231
|
+
"""Fetch every round's matches for one season concurrently.
|
|
232
|
+
|
|
233
|
+
Per-round calls are launched via ``asyncio.gather``; the shared rate
|
|
234
|
+
limiter still serializes them, so total time is the same as the sync
|
|
235
|
+
version. Async lets *other* coroutines do work in the meantime.
|
|
236
|
+
"""
|
|
237
|
+
rounds = await self.tournament_rounds(tournament_id, season_id)
|
|
238
|
+
|
|
239
|
+
async def _fetch_round(round_number: int, slug: str | None) -> tuple[int, MatchList]:
|
|
240
|
+
ml = await self.round_events(tournament_id, season_id, round_number, slug)
|
|
241
|
+
return round_number, ml
|
|
242
|
+
|
|
243
|
+
tasks = [_fetch_round(r.round, r.slug) for r in rounds.rounds]
|
|
244
|
+
results = await asyncio.gather(*tasks) if tasks else []
|
|
245
|
+
return dict(results)
|
|
246
|
+
|
|
247
|
+
# ----- standings & profiles -----
|
|
248
|
+
|
|
249
|
+
async def tournament_standings(
|
|
250
|
+
self,
|
|
251
|
+
tournament_id: int,
|
|
252
|
+
season_id: int,
|
|
253
|
+
table_type: str = "total",
|
|
254
|
+
) -> Standings:
|
|
255
|
+
"""League table for a tournament season."""
|
|
256
|
+
raw = await self._http.get(
|
|
257
|
+
f"unique-tournament/{tournament_id}/season/{season_id}/standings/{table_type}"
|
|
258
|
+
)
|
|
259
|
+
return parse_standings(raw)
|
|
260
|
+
|
|
261
|
+
async def team(self, team_id: int) -> TeamProfile:
|
|
262
|
+
"""Team profile — logo, country, venue, manager."""
|
|
263
|
+
raw = await self._http.get(f"team/{team_id}")
|
|
264
|
+
return parse_team_profile(raw)
|
|
265
|
+
|
|
266
|
+
async def player(self, player_id: int) -> PlayerProfile:
|
|
267
|
+
"""Player profile — bio, position, market value."""
|
|
268
|
+
raw = await self._http.get(f"player/{player_id}")
|
|
269
|
+
return parse_player_profile(raw)
|
|
270
|
+
|
|
271
|
+
async def player_season_statistics(
|
|
272
|
+
self,
|
|
273
|
+
player_id: int,
|
|
274
|
+
tournament_id: int,
|
|
275
|
+
season_id: int,
|
|
276
|
+
) -> SeasonPlayerStatistics:
|
|
277
|
+
"""Season aggregate statistics for one player in one competition."""
|
|
278
|
+
raw = await self._http.get(
|
|
279
|
+
f"player/{player_id}/unique-tournament/{tournament_id}/season/{season_id}/statistics/overall"
|
|
280
|
+
)
|
|
281
|
+
return parse_season_player_statistics(raw)
|
|
282
|
+
|
|
283
|
+
async def team_events(self, team_id: int, page: int = 0) -> MatchList:
|
|
284
|
+
"""Recent fixtures for a team."""
|
|
285
|
+
raw = await self._http.get(f"team/{team_id}/events/last/{page}")
|
|
286
|
+
return parse_match_list(raw)
|
|
287
|
+
|
|
288
|
+
async def team_events_next(self, team_id: int, page: int = 0) -> MatchList:
|
|
289
|
+
"""Upcoming fixtures for a team."""
|
|
290
|
+
raw = await self._http.get(f"team/{team_id}/events/next/{page}")
|
|
291
|
+
return parse_match_list(raw)
|
|
292
|
+
|
|
293
|
+
async def team_rankings(self, team_id: int) -> TeamRankings:
|
|
294
|
+
"""FIFA-style ranking rows for a team."""
|
|
295
|
+
raw = await self._http.get(f"team/{team_id}/rankings")
|
|
296
|
+
return parse_team_rankings(raw)
|
|
297
|
+
|
|
298
|
+
async def player_transfer_history(self, player_id: int) -> TransferHistory:
|
|
299
|
+
"""Career transfer history for a player."""
|
|
300
|
+
raw = await self._http.get(f"player/{player_id}/transfer-history")
|
|
301
|
+
return parse_transfer_history(raw)
|
|
302
|
+
|
|
303
|
+
async def player_events_last(self, player_id: int, page: int = 0) -> MatchList:
|
|
304
|
+
"""Recent matches involving a player."""
|
|
305
|
+
raw = await self._http.get(f"player/{player_id}/events/last/{page}")
|
|
306
|
+
return parse_match_list(raw)
|
|
307
|
+
|
|
308
|
+
# ----- high-level workflow -----
|
|
309
|
+
|
|
310
|
+
async def fetch_full_match(
|
|
311
|
+
self,
|
|
312
|
+
event_id: int,
|
|
313
|
+
skip_no_minutes: bool = True,
|
|
314
|
+
skip_no_shots: bool = True,
|
|
315
|
+
skip_sparse_heatmap: bool = True,
|
|
316
|
+
min_touches_for_heatmap: int = 5,
|
|
317
|
+
include_match_shotmap: bool = True,
|
|
318
|
+
include_h2h: bool = True,
|
|
319
|
+
include_pregame_form: bool = True,
|
|
320
|
+
) -> FullMatch:
|
|
321
|
+
"""Fetch analytics endpoints for one match (mirrors sync client)."""
|
|
322
|
+
event_detail = await self.event(event_id)
|
|
323
|
+
lineups = await self.event_lineups(event_id)
|
|
324
|
+
team_stats = await self.event_statistics(event_id)
|
|
325
|
+
|
|
326
|
+
incidents: Incidents | None = None
|
|
327
|
+
managers: EventManagers | None = None
|
|
328
|
+
graph: MatchGraph | None = None
|
|
329
|
+
head_to_head: HeadToHead | None = None
|
|
330
|
+
pregame_form: PregameForm | None = None
|
|
331
|
+
match_shotmap: Shotmap | None = None
|
|
332
|
+
|
|
333
|
+
with contextlib.suppress(NotFoundError):
|
|
334
|
+
incidents = await self.event_incidents(event_id)
|
|
335
|
+
with contextlib.suppress(NotFoundError):
|
|
336
|
+
managers = await self.event_managers(event_id)
|
|
337
|
+
with contextlib.suppress(NotFoundError):
|
|
338
|
+
graph = await self.event_graph(event_id)
|
|
339
|
+
if include_h2h:
|
|
340
|
+
with contextlib.suppress(NotFoundError):
|
|
341
|
+
head_to_head = await self.event_h2h(event_id)
|
|
342
|
+
if include_pregame_form:
|
|
343
|
+
with contextlib.suppress(NotFoundError):
|
|
344
|
+
pregame_form = await self.event_pregame_form(event_id)
|
|
345
|
+
if include_match_shotmap:
|
|
346
|
+
with contextlib.suppress(NotFoundError):
|
|
347
|
+
match_shotmap = await self.event_shotmap(event_id)
|
|
348
|
+
|
|
349
|
+
async def _fetch_one(entry: Any) -> tuple[int, PlayerMatchData]:
|
|
350
|
+
stats = entry.statistics
|
|
351
|
+
player_id = entry.player.id
|
|
352
|
+
heatmap: Heatmap | None = None
|
|
353
|
+
actions: ActionStream | None = None
|
|
354
|
+
shotmap: Shotmap | None = None
|
|
355
|
+
|
|
356
|
+
if should_fetch_heatmap(
|
|
357
|
+
stats.touches,
|
|
358
|
+
skip_sparse=skip_sparse_heatmap,
|
|
359
|
+
min_touches=min_touches_for_heatmap,
|
|
360
|
+
):
|
|
361
|
+
with contextlib.suppress(NotFoundError):
|
|
362
|
+
heatmap = await self.player_heatmap(event_id, player_id)
|
|
363
|
+
|
|
364
|
+
with contextlib.suppress(NotFoundError):
|
|
365
|
+
actions = await self.player_actions(event_id, player_id)
|
|
366
|
+
|
|
367
|
+
if not skip_no_shots or should_fetch_shotmap(stats.total_shots):
|
|
368
|
+
with contextlib.suppress(NotFoundError):
|
|
369
|
+
shotmap = await self.player_shotmap(event_id, player_id)
|
|
370
|
+
|
|
371
|
+
return player_id, PlayerMatchData(
|
|
372
|
+
player_id=player_id,
|
|
373
|
+
heatmap=heatmap,
|
|
374
|
+
actions=actions,
|
|
375
|
+
shotmap=shotmap,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
tasks = [
|
|
379
|
+
_fetch_one(entry)
|
|
380
|
+
for entry in lineups.all_players()
|
|
381
|
+
if should_fetch_spatial(
|
|
382
|
+
entry.statistics.minutes_played, skip_no_minutes=skip_no_minutes
|
|
383
|
+
)
|
|
384
|
+
]
|
|
385
|
+
results = await asyncio.gather(*tasks) if tasks else []
|
|
386
|
+
|
|
387
|
+
return FullMatch(
|
|
388
|
+
event_id=event_id,
|
|
389
|
+
team_statistics=team_stats,
|
|
390
|
+
lineups=lineups,
|
|
391
|
+
player_data=dict(results),
|
|
392
|
+
event_detail=event_detail,
|
|
393
|
+
incidents=incidents,
|
|
394
|
+
graph=graph,
|
|
395
|
+
managers=managers,
|
|
396
|
+
head_to_head=head_to_head,
|
|
397
|
+
pregame_form=pregame_form,
|
|
398
|
+
match_shotmap=match_shotmap,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# ----- lifecycle -----
|
|
402
|
+
|
|
403
|
+
async def close(self) -> None:
|
|
404
|
+
"""Close the underlying HTTP connection pool."""
|
|
405
|
+
await self._http.close()
|
|
406
|
+
|
|
407
|
+
async def __aenter__(self) -> AsyncSofascoreClient:
|
|
408
|
+
return self
|
|
409
|
+
|
|
410
|
+
async def __aexit__(self, *args: object) -> None:
|
|
411
|
+
await self.close()
|
tacoscore/async_http.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""asyncio-compatible rate limiter.
|
|
2
|
+
|
|
3
|
+
Mirrors :class:`tacoscore.rate_limiter.RateLimiter` but uses ``asyncio.Lock``
|
|
4
|
+
and ``asyncio.sleep`` so it doesn't block the event loop.
|
|
5
|
+
|
|
6
|
+
Note:
|
|
7
|
+
Sharing one limiter across many concurrent coroutines still serializes
|
|
8
|
+
their requests one-per-interval — the limiter holds its lock during the
|
|
9
|
+
sleep. Async doesn't make rate-limited scraping faster; it just lets
|
|
10
|
+
other coroutines do work *during* the wait.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import random
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsyncRateLimiter:
|
|
21
|
+
"""Async rate limiter with optional jitter.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
min_interval: Minimum seconds between calls.
|
|
25
|
+
jitter: Random uniform noise added to each interval (\u00b1 jitter / 2).
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
>>> limiter = AsyncRateLimiter(min_interval=1.5, jitter=0.5)
|
|
29
|
+
>>> await limiter.wait() # Returns 0 on first call
|
|
30
|
+
>>> await limiter.wait() # Sleeps 1.0-2.0 seconds
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, min_interval: float = 1.5, jitter: float = 0.5) -> None:
|
|
34
|
+
if min_interval < 0:
|
|
35
|
+
raise ValueError("min_interval must be non-negative")
|
|
36
|
+
if jitter < 0:
|
|
37
|
+
raise ValueError("jitter must be non-negative")
|
|
38
|
+
self.min_interval = min_interval
|
|
39
|
+
self.jitter = jitter
|
|
40
|
+
self._lock = asyncio.Lock()
|
|
41
|
+
self._last_call: float = 0.0
|
|
42
|
+
|
|
43
|
+
async def wait(self) -> float:
|
|
44
|
+
"""Block until the next call is allowed.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Seconds actually slept (zero for the first call).
|
|
48
|
+
"""
|
|
49
|
+
async with self._lock:
|
|
50
|
+
now = time.monotonic()
|
|
51
|
+
elapsed = now - self._last_call
|
|
52
|
+
target = self.min_interval + random.uniform(
|
|
53
|
+
-self.jitter / 2, self.jitter / 2
|
|
54
|
+
)
|
|
55
|
+
sleep_for = max(0.0, target - elapsed)
|
|
56
|
+
if sleep_for > 0:
|
|
57
|
+
await asyncio.sleep(sleep_for)
|
|
58
|
+
self._last_call = time.monotonic()
|
|
59
|
+
return sleep_for
|
|
60
|
+
|
|
61
|
+
async def reset(self) -> None:
|
|
62
|
+
"""Reset the limiter so the next call returns immediately."""
|
|
63
|
+
async with self._lock:
|
|
64
|
+
self._last_call = 0.0
|