openmatchkit 0.2.1__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.
- openmatchkit/__init__.py +41 -0
- openmatchkit/cli.py +182 -0
- openmatchkit/client.py +405 -0
- openmatchkit/exceptions.py +14 -0
- openmatchkit/export.py +94 -0
- openmatchkit/http.py +181 -0
- openmatchkit/models.py +198 -0
- openmatchkit/prediction/__init__.py +7 -0
- openmatchkit/prediction/elo.py +43 -0
- openmatchkit/prediction/poisson.py +99 -0
- openmatchkit/sources/__init__.py +11 -0
- openmatchkit/sources/base.py +29 -0
- openmatchkit/sources/football_data_uk.py +121 -0
- openmatchkit/sources/json_file.py +189 -0
- openmatchkit/sources/openfootball.py +235 -0
- openmatchkit/sources/public_html.py +41 -0
- openmatchkit-0.2.1.dist-info/METADATA +192 -0
- openmatchkit-0.2.1.dist-info/RECORD +20 -0
- openmatchkit-0.2.1.dist-info/WHEEL +4 -0
- openmatchkit-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from hashlib import sha1
|
|
6
|
+
from io import StringIO
|
|
7
|
+
|
|
8
|
+
from openmatchkit.http import SafeHttpClient
|
|
9
|
+
from openmatchkit.models import Match, MatchStatus, Score, Team
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FootballDataUkSource:
|
|
13
|
+
"""Adapter for football-data.co.uk CSV result files.
|
|
14
|
+
|
|
15
|
+
The source is free to download, but users should verify the site's current
|
|
16
|
+
terms before redistributing data. The adapter fetches facts from source and
|
|
17
|
+
does not bundle third-party data.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name = "football_data_uk"
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
http: SafeHttpClient | None = None,
|
|
25
|
+
base_url: str = "https://www.football-data.co.uk/mmz4281",
|
|
26
|
+
) -> None:
|
|
27
|
+
self.http = http or SafeHttpClient()
|
|
28
|
+
self.base_url = base_url.rstrip("/")
|
|
29
|
+
|
|
30
|
+
def _make_id(self, *parts: object) -> str:
|
|
31
|
+
raw = "|".join(str(part) for part in parts)
|
|
32
|
+
return sha1(raw.encode("utf-8")).hexdigest()[:16]
|
|
33
|
+
|
|
34
|
+
def csv_url(self, competition: str, season: str) -> str:
|
|
35
|
+
return f"{self.base_url}/{season}/{competition}.csv"
|
|
36
|
+
|
|
37
|
+
def _parse_date(self, value: str) -> datetime | None:
|
|
38
|
+
value = value.strip()
|
|
39
|
+
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
|
40
|
+
try:
|
|
41
|
+
return datetime.strptime(value, fmt)
|
|
42
|
+
except ValueError:
|
|
43
|
+
continue
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def _parse_int(self, value: object) -> int | None:
|
|
47
|
+
if value is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
text = str(value).strip()
|
|
51
|
+
if not text:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
return int(float(text))
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def parse_csv(
|
|
60
|
+
self,
|
|
61
|
+
text: str,
|
|
62
|
+
*,
|
|
63
|
+
source_url: str,
|
|
64
|
+
competition: str,
|
|
65
|
+
season: str,
|
|
66
|
+
fetched_at: datetime | None = None,
|
|
67
|
+
) -> list[Match]:
|
|
68
|
+
fetched = fetched_at or datetime.now(timezone.utc)
|
|
69
|
+
reader = csv.DictReader(StringIO(text))
|
|
70
|
+
matches: list[Match] = []
|
|
71
|
+
|
|
72
|
+
for row in reader:
|
|
73
|
+
home = (row.get("HomeTeam") or "").strip()
|
|
74
|
+
away = (row.get("AwayTeam") or "").strip()
|
|
75
|
+
|
|
76
|
+
if not home or not away:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
home_goals = self._parse_int(row.get("FTHG"))
|
|
80
|
+
away_goals = self._parse_int(row.get("FTAG"))
|
|
81
|
+
has_score = home_goals is not None and away_goals is not None
|
|
82
|
+
status = MatchStatus.FULL_TIME if has_score else MatchStatus.SCHEDULED
|
|
83
|
+
kickoff = self._parse_date(row.get("Date") or "")
|
|
84
|
+
|
|
85
|
+
match_id = self._make_id(
|
|
86
|
+
self.name,
|
|
87
|
+
competition,
|
|
88
|
+
season,
|
|
89
|
+
row.get("Date"),
|
|
90
|
+
home,
|
|
91
|
+
away,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
matches.append(
|
|
95
|
+
Match(
|
|
96
|
+
match_id=match_id,
|
|
97
|
+
competition=competition,
|
|
98
|
+
season=season,
|
|
99
|
+
kickoff=kickoff,
|
|
100
|
+
home=Team(name=home),
|
|
101
|
+
away=Team(name=away),
|
|
102
|
+
score=Score(home=home_goals, away=away_goals),
|
|
103
|
+
status=status,
|
|
104
|
+
source=self.name,
|
|
105
|
+
source_url=source_url,
|
|
106
|
+
fetched_at=fetched,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return matches
|
|
111
|
+
|
|
112
|
+
def fixtures(self, competition: str, season: str | None = None) -> list[Match]:
|
|
113
|
+
if season is None:
|
|
114
|
+
raise ValueError("Football-Data adapter requires a season code, for example '2324'.")
|
|
115
|
+
|
|
116
|
+
url = self.csv_url(competition=competition, season=season)
|
|
117
|
+
text = self.http.get_text(url)
|
|
118
|
+
return self.parse_csv(text, source_url=url, competition=competition, season=season)
|
|
119
|
+
|
|
120
|
+
def live_scores(self) -> list[Match]:
|
|
121
|
+
return []
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from openmatchkit.models import (
|
|
8
|
+
Match,
|
|
9
|
+
MatchStatus,
|
|
10
|
+
Player,
|
|
11
|
+
PlayerHistory,
|
|
12
|
+
PlayerMatchAppearance,
|
|
13
|
+
PlayerTotals,
|
|
14
|
+
Scoreboard,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JsonFileSource:
|
|
19
|
+
"""Source adapter for local detailed JSON files.
|
|
20
|
+
|
|
21
|
+
This is useful for authorized/public detailed feeds, saved fixtures, and demos.
|
|
22
|
+
It never performs network requests.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "json_file"
|
|
26
|
+
|
|
27
|
+
def __init__(self, path: str | Path) -> None:
|
|
28
|
+
self.path = Path(path)
|
|
29
|
+
self.source_url = self.path.resolve().as_uri()
|
|
30
|
+
self._data: dict[str, Any] | None = None
|
|
31
|
+
|
|
32
|
+
def _load(self) -> dict[str, Any]:
|
|
33
|
+
if self._data is None:
|
|
34
|
+
self._data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
35
|
+
return self._data
|
|
36
|
+
|
|
37
|
+
def _scoreboards(self) -> list[Scoreboard]:
|
|
38
|
+
data = self._load()
|
|
39
|
+
return [Scoreboard.model_validate(item) for item in data.get("scoreboards", [])]
|
|
40
|
+
|
|
41
|
+
def _matches(self) -> list[Match]:
|
|
42
|
+
data = self._load()
|
|
43
|
+
matches: dict[str, Match] = {}
|
|
44
|
+
|
|
45
|
+
for item in data.get("matches", []):
|
|
46
|
+
match = Match.model_validate(item)
|
|
47
|
+
matches[match.match_id] = match
|
|
48
|
+
|
|
49
|
+
for board in self._scoreboards():
|
|
50
|
+
matches[board.match.match_id] = board.match
|
|
51
|
+
|
|
52
|
+
return list(matches.values())
|
|
53
|
+
|
|
54
|
+
def _match_filter(
|
|
55
|
+
self,
|
|
56
|
+
match: Match,
|
|
57
|
+
competition: str | None = None,
|
|
58
|
+
season: str | None = None,
|
|
59
|
+
) -> bool:
|
|
60
|
+
if competition is not None and match.competition != competition:
|
|
61
|
+
return False
|
|
62
|
+
if season is not None and match.season != season:
|
|
63
|
+
return False
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
def fixtures(self, competition: str, season: str | None = None) -> list[Match]:
|
|
67
|
+
return [
|
|
68
|
+
match
|
|
69
|
+
for match in self._matches()
|
|
70
|
+
if self._match_filter(match, competition=competition, season=season)
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
def live_scores(self) -> list[Match]:
|
|
74
|
+
return [board.match for board in self.live_scoreboards()]
|
|
75
|
+
|
|
76
|
+
def scoreboards(
|
|
77
|
+
self,
|
|
78
|
+
competition: str | None = None,
|
|
79
|
+
season: str | None = None,
|
|
80
|
+
) -> list[Scoreboard]:
|
|
81
|
+
return [
|
|
82
|
+
board
|
|
83
|
+
for board in self._scoreboards()
|
|
84
|
+
if self._match_filter(board.match, competition=competition, season=season)
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
def scoreboard(
|
|
88
|
+
self,
|
|
89
|
+
match_id: str,
|
|
90
|
+
competition: str | None = None,
|
|
91
|
+
season: str | None = None,
|
|
92
|
+
) -> Scoreboard | None:
|
|
93
|
+
for board in self.scoreboards(competition=competition, season=season):
|
|
94
|
+
if board.match.match_id == match_id:
|
|
95
|
+
return board
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def live_scoreboards(self) -> list[Scoreboard]:
|
|
99
|
+
return [
|
|
100
|
+
board
|
|
101
|
+
for board in self._scoreboards()
|
|
102
|
+
if board.match.status in {MatchStatus.LIVE, MatchStatus.HALF_TIME}
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
def _appearance_from_stats(
|
|
106
|
+
self,
|
|
107
|
+
board: Scoreboard,
|
|
108
|
+
player_name: str,
|
|
109
|
+
) -> PlayerMatchAppearance | None:
|
|
110
|
+
needle = player_name.casefold()
|
|
111
|
+
|
|
112
|
+
for stats in board.player_stats:
|
|
113
|
+
if stats.player.name.casefold() != needle:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
team = stats.player.team or ""
|
|
117
|
+
opponent = None
|
|
118
|
+
home_away = "unknown"
|
|
119
|
+
|
|
120
|
+
if team == board.match.home.name:
|
|
121
|
+
opponent = board.match.away.name
|
|
122
|
+
home_away = "home"
|
|
123
|
+
elif team == board.match.away.name:
|
|
124
|
+
opponent = board.match.home.name
|
|
125
|
+
home_away = "away"
|
|
126
|
+
elif team:
|
|
127
|
+
home_away = "neutral"
|
|
128
|
+
|
|
129
|
+
return PlayerMatchAppearance(
|
|
130
|
+
match_id=board.match.match_id,
|
|
131
|
+
competition=board.match.competition,
|
|
132
|
+
season=board.match.season,
|
|
133
|
+
kickoff=board.match.kickoff,
|
|
134
|
+
team=team or "unknown",
|
|
135
|
+
opponent=opponent,
|
|
136
|
+
home_away=home_away,
|
|
137
|
+
status=board.match.status,
|
|
138
|
+
minutes_played=stats.minutes_played,
|
|
139
|
+
started=stats.started,
|
|
140
|
+
goals=stats.goals,
|
|
141
|
+
assists=stats.assists,
|
|
142
|
+
yellow_cards=stats.yellow_cards,
|
|
143
|
+
red_cards=stats.red_cards,
|
|
144
|
+
source=stats.source or board.match.source,
|
|
145
|
+
source_url=board.match.source_url,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def player_history(
|
|
151
|
+
self,
|
|
152
|
+
player: str,
|
|
153
|
+
competition: str | None = None,
|
|
154
|
+
season: str | None = None,
|
|
155
|
+
) -> PlayerHistory:
|
|
156
|
+
appearances: list[PlayerMatchAppearance] = []
|
|
157
|
+
player_model = Player(name=player)
|
|
158
|
+
|
|
159
|
+
for board in self.scoreboards(competition=competition, season=season):
|
|
160
|
+
appearance = self._appearance_from_stats(board, player)
|
|
161
|
+
if appearance is None:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
appearances.append(appearance)
|
|
165
|
+
for stats in board.player_stats:
|
|
166
|
+
if stats.player.name.casefold() == player.casefold():
|
|
167
|
+
player_model = stats.player
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
totals = PlayerTotals(
|
|
171
|
+
appearances=len(appearances),
|
|
172
|
+
starts=sum(1 for item in appearances if item.started is True),
|
|
173
|
+
minutes_played=sum(item.minutes_played or 0 for item in appearances),
|
|
174
|
+
goals=sum(item.goals for item in appearances),
|
|
175
|
+
assists=sum(item.assists for item in appearances),
|
|
176
|
+
yellow_cards=sum(item.yellow_cards for item in appearances),
|
|
177
|
+
red_cards=sum(item.red_cards for item in appearances),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
notes = []
|
|
181
|
+
if not appearances:
|
|
182
|
+
notes.append("No player-level appearances found in this JSON source.")
|
|
183
|
+
|
|
184
|
+
return PlayerHistory(
|
|
185
|
+
player=player_model,
|
|
186
|
+
totals=totals,
|
|
187
|
+
appearances=appearances,
|
|
188
|
+
source_notes=notes,
|
|
189
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from hashlib import sha1
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from openmatchkit.exceptions import ParseError
|
|
9
|
+
from openmatchkit.http import SafeHttpClient
|
|
10
|
+
from openmatchkit.models import Match, MatchStatus, Player, PlayerHistory, Score, Scoreboard, Team
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenFootballSource:
|
|
14
|
+
"""Adapter for OpenFootball JSON datasets."""
|
|
15
|
+
|
|
16
|
+
name = "openfootball"
|
|
17
|
+
|
|
18
|
+
WORLD_CUP_ALIASES = {
|
|
19
|
+
"fifa-world-cup",
|
|
20
|
+
"fifa_world_cup",
|
|
21
|
+
"world-cup",
|
|
22
|
+
"world_cup",
|
|
23
|
+
"worldcup",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
http: SafeHttpClient | None = None,
|
|
29
|
+
league_base_url: str = "https://raw.githubusercontent.com/openfootball/football.json/master",
|
|
30
|
+
worldcup_base_url: str = "https://raw.githubusercontent.com/openfootball/worldcup.json/master",
|
|
31
|
+
) -> None:
|
|
32
|
+
self.http = http or SafeHttpClient()
|
|
33
|
+
self.league_base_url = league_base_url.rstrip("/")
|
|
34
|
+
self.worldcup_base_url = worldcup_base_url.rstrip("/")
|
|
35
|
+
|
|
36
|
+
def _make_id(self, *parts: object) -> str:
|
|
37
|
+
raw = "|".join(str(part) for part in parts)
|
|
38
|
+
return sha1(raw.encode("utf-8")).hexdigest()[:16]
|
|
39
|
+
|
|
40
|
+
def dataset_url(self, competition: str, season: str) -> str:
|
|
41
|
+
normalized = competition.strip().lower()
|
|
42
|
+
|
|
43
|
+
if normalized in self.WORLD_CUP_ALIASES:
|
|
44
|
+
return f"{self.worldcup_base_url}/{season}/worldcup.json"
|
|
45
|
+
|
|
46
|
+
return f"{self.league_base_url}/{season}/{competition}.json"
|
|
47
|
+
|
|
48
|
+
def league_json(self, season: str, league_code: str) -> dict[str, Any]:
|
|
49
|
+
url = self.dataset_url(competition=league_code, season=season)
|
|
50
|
+
data = self.http.get_json(url)
|
|
51
|
+
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
raise ParseError(f"Expected JSON object from {url}")
|
|
54
|
+
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
def _parse_kickoff(
|
|
58
|
+
self, date_value: object, time_value: object | None = None
|
|
59
|
+
) -> datetime | None:
|
|
60
|
+
if not date_value:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
date_text = str(date_value).strip()
|
|
64
|
+
time_text = str(time_value).strip() if time_value else ""
|
|
65
|
+
|
|
66
|
+
if not time_text:
|
|
67
|
+
try:
|
|
68
|
+
return datetime.fromisoformat(date_text)
|
|
69
|
+
except ValueError:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
tzinfo = None
|
|
73
|
+
time_part = time_text
|
|
74
|
+
match = re.search(r"UTC\s*([+-]\d{1,2})(?::?(\d{2}))?", time_text)
|
|
75
|
+
if match:
|
|
76
|
+
hours = int(match.group(1))
|
|
77
|
+
minutes = int(match.group(2) or "0")
|
|
78
|
+
sign = 1 if hours >= 0 else -1
|
|
79
|
+
tzinfo = timezone(timedelta(hours=hours, minutes=sign * minutes))
|
|
80
|
+
time_part = time_text.split("UTC", 1)[0].strip()
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
parsed = datetime.fromisoformat(f"{date_text}T{time_part}")
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
if tzinfo is not None:
|
|
88
|
+
parsed = parsed.replace(tzinfo=tzinfo)
|
|
89
|
+
|
|
90
|
+
return parsed
|
|
91
|
+
|
|
92
|
+
def _score_from_item(self, item: dict[str, Any]) -> tuple[Score, MatchStatus]:
|
|
93
|
+
score_data = item.get("score") or {}
|
|
94
|
+
ft_score = score_data.get("ft") if isinstance(score_data, dict) else None
|
|
95
|
+
|
|
96
|
+
if not isinstance(ft_score, list) or len(ft_score) < 2:
|
|
97
|
+
return Score(), MatchStatus.SCHEDULED
|
|
98
|
+
|
|
99
|
+
home, away = ft_score[0], ft_score[1]
|
|
100
|
+
if home is None or away is None:
|
|
101
|
+
return Score(), MatchStatus.SCHEDULED
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
return Score(home=int(home), away=int(away)), MatchStatus.FULL_TIME
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
return Score(), MatchStatus.UNKNOWN
|
|
107
|
+
|
|
108
|
+
def parse_matches(
|
|
109
|
+
self,
|
|
110
|
+
data: dict[str, Any],
|
|
111
|
+
*,
|
|
112
|
+
source_url: str,
|
|
113
|
+
competition: str,
|
|
114
|
+
season: str,
|
|
115
|
+
fetched_at: datetime | None = None,
|
|
116
|
+
) -> list[Match]:
|
|
117
|
+
fetched = fetched_at or datetime.now(timezone.utc)
|
|
118
|
+
raw_matches = data.get("matches", [])
|
|
119
|
+
|
|
120
|
+
if not isinstance(raw_matches, list):
|
|
121
|
+
raise ParseError("OpenFootball JSON did not contain a list at 'matches'.")
|
|
122
|
+
|
|
123
|
+
competition_name = str(data.get("name") or competition)
|
|
124
|
+
matches: list[Match] = []
|
|
125
|
+
|
|
126
|
+
for item in raw_matches:
|
|
127
|
+
if not isinstance(item, dict):
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
home_name = item.get("team1")
|
|
131
|
+
away_name = item.get("team2")
|
|
132
|
+
if not home_name or not away_name:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
score, status = self._score_from_item(item)
|
|
136
|
+
kickoff = self._parse_kickoff(item.get("date"), item.get("time"))
|
|
137
|
+
|
|
138
|
+
match_id = self._make_id(
|
|
139
|
+
self.name,
|
|
140
|
+
competition_name,
|
|
141
|
+
season,
|
|
142
|
+
item.get("round"),
|
|
143
|
+
item.get("group"),
|
|
144
|
+
item.get("date"),
|
|
145
|
+
item.get("time"),
|
|
146
|
+
home_name,
|
|
147
|
+
away_name,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
matches.append(
|
|
151
|
+
Match(
|
|
152
|
+
match_id=match_id,
|
|
153
|
+
competition=competition_name,
|
|
154
|
+
season=season,
|
|
155
|
+
round=item.get("round") or item.get("stage"),
|
|
156
|
+
group=item.get("group"),
|
|
157
|
+
kickoff=kickoff,
|
|
158
|
+
home=Team(name=str(home_name)),
|
|
159
|
+
away=Team(name=str(away_name)),
|
|
160
|
+
score=score,
|
|
161
|
+
status=status,
|
|
162
|
+
venue=item.get("ground") or item.get("venue"),
|
|
163
|
+
source=self.name,
|
|
164
|
+
source_url=source_url,
|
|
165
|
+
fetched_at=fetched,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return matches
|
|
170
|
+
|
|
171
|
+
def fixtures(self, competition: str, season: str | None = None) -> list[Match]:
|
|
172
|
+
if season is None:
|
|
173
|
+
raise ValueError("OpenFootball fixtures require a season, for example '2026'.")
|
|
174
|
+
|
|
175
|
+
url = self.dataset_url(competition=competition, season=season)
|
|
176
|
+
data = self.league_json(season=season, league_code=competition)
|
|
177
|
+
return self.parse_matches(data, source_url=url, competition=competition, season=season)
|
|
178
|
+
|
|
179
|
+
def live_scores(self) -> list[Match]:
|
|
180
|
+
return []
|
|
181
|
+
|
|
182
|
+
def scoreboard(
|
|
183
|
+
self,
|
|
184
|
+
match_id: str,
|
|
185
|
+
competition: str | None = None,
|
|
186
|
+
season: str | None = None,
|
|
187
|
+
) -> Scoreboard | None:
|
|
188
|
+
if competition is None or season is None:
|
|
189
|
+
raise ValueError("OpenFootball scoreboard lookup requires competition and season.")
|
|
190
|
+
|
|
191
|
+
for match in self.fixtures(competition=competition, season=season):
|
|
192
|
+
if match.match_id == match_id:
|
|
193
|
+
return Scoreboard(
|
|
194
|
+
match=match,
|
|
195
|
+
source_notes=[
|
|
196
|
+
"OpenFootball provides fixtures/results only for this match.",
|
|
197
|
+
"Player stats, lineups, detailed events, and live clock are not available from this source.",
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def scoreboards(
|
|
203
|
+
self,
|
|
204
|
+
competition: str | None = None,
|
|
205
|
+
season: str | None = None,
|
|
206
|
+
) -> list[Scoreboard]:
|
|
207
|
+
if competition is None or season is None:
|
|
208
|
+
raise ValueError("OpenFootball scoreboards require competition and season.")
|
|
209
|
+
|
|
210
|
+
return [
|
|
211
|
+
Scoreboard(
|
|
212
|
+
match=match,
|
|
213
|
+
source_notes=[
|
|
214
|
+
"OpenFootball provides fixtures/results only for this match.",
|
|
215
|
+
"Player stats, lineups, detailed events, and live clock are not available from this source.",
|
|
216
|
+
],
|
|
217
|
+
)
|
|
218
|
+
for match in self.fixtures(competition=competition, season=season)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
def live_scoreboards(self) -> list[Scoreboard]:
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
def player_history(
|
|
225
|
+
self,
|
|
226
|
+
player: str,
|
|
227
|
+
competition: str | None = None,
|
|
228
|
+
season: str | None = None,
|
|
229
|
+
) -> PlayerHistory:
|
|
230
|
+
return PlayerHistory(
|
|
231
|
+
player=Player(name=player),
|
|
232
|
+
source_notes=[
|
|
233
|
+
"OpenFootball does not provide player-level match histories in this adapter."
|
|
234
|
+
],
|
|
235
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
|
|
8
|
+
from openmatchkit.http import SafeHttpClient
|
|
9
|
+
from openmatchkit.models import Match
|
|
10
|
+
|
|
11
|
+
HtmlParser = Callable[[BeautifulSoup, str, datetime], list[Match]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PublicHtmlSource:
|
|
15
|
+
"""Generic public HTML adapter.
|
|
16
|
+
|
|
17
|
+
It is intentionally parser-driven. Site-specific parsers should live in
|
|
18
|
+
optional modules and include source-specific policy notes and fixture tests.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name = "public_html"
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
url: str,
|
|
26
|
+
parser: HtmlParser,
|
|
27
|
+
http: SafeHttpClient | None = None,
|
|
28
|
+
min_delay_seconds: float = 60.0,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.url = url
|
|
31
|
+
self.parser = parser
|
|
32
|
+
self.http = http or SafeHttpClient(min_delay_seconds=min_delay_seconds)
|
|
33
|
+
|
|
34
|
+
def live_scores(self) -> list[Match]:
|
|
35
|
+
html = self.http.get_text(self.url)
|
|
36
|
+
soup = BeautifulSoup(html, "lxml")
|
|
37
|
+
fetched_at = datetime.now(timezone.utc)
|
|
38
|
+
return self.parser(soup, self.url, fetched_at)
|
|
39
|
+
|
|
40
|
+
def fixtures(self, competition: str, season: str | None = None) -> list[Match]:
|
|
41
|
+
return self.live_scores()
|