ps3838api 1.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.
@@ -0,0 +1,256 @@
1
+ """
2
+ Sport identifiers exposed by the PS3838 API.
3
+ """
4
+
5
+ from enum import IntEnum
6
+
7
+
8
+ class Sport(IntEnum):
9
+ BADMINTON_SPORT_ID = 1
10
+ BANDY_SPORT_ID = 2
11
+ BASEBALL_SPORT_ID = 3
12
+ BASKETBALL_SPORT_ID = 4
13
+ BEACH_VOLLEYBALL_SPORT_ID = 5
14
+ BOXING_SPORT_ID = 6
15
+ CHESS_SPORT_ID = 7
16
+ CRICKET_SPORT_ID = 8
17
+ CURLING_SPORT_ID = 9
18
+ DARTS_SPORT_ID = 10
19
+ FIELD_HOCKEY_SPORT_ID = 13
20
+ FLOORBALL_SPORT_ID = 14
21
+ FOOTBALL_SPORT_ID = 15
22
+ FUTSAL_SPORT_ID = 16
23
+ GOLF_SPORT_ID = 17
24
+ HANDBALL_SPORT_ID = 18
25
+ HOCKEY_SPORT_ID = 19
26
+ HORSE_RACING_SPECIALS_SPORT_ID = 20
27
+ LACROSSE_SPORT_ID = 21
28
+ MMA_SPORT_ID = 22
29
+ OTHER_SPORTS_SPORT_ID = 23
30
+ POLITICS_SPORT_ID = 24
31
+ RUGBY_LEAGUE_SPORT_ID = 26
32
+ RUGBY_UNION_SPORT_ID = 27
33
+ SNOOKER_SPORT_ID = 28
34
+ SOCCER_SPORT_ID = 29
35
+ SOFTBALL_SPORT_ID = 30
36
+ SQUASH_SPORT_ID = 31
37
+ TABLE_TENNIS_SPORT_ID = 32
38
+ TENNIS_SPORT_ID = 33
39
+ VOLLEYBALL_SPORT_ID = 34
40
+ WATER_POLO_SPORT_ID = 36
41
+ PADEL_TENNIS_SPORT_ID = 37
42
+ AUSSIE_RULES_SPORT_ID = 39
43
+ ALPINE_SKIING_SPORT_ID = 40
44
+ BIATHLON_SPORT_ID = 41
45
+ SKI_JUMPING_SPORT_ID = 42
46
+ CROSS_COUNTRY_SPORT_ID = 43
47
+ FORMULA1_SPORT_ID = 44
48
+ CYCLING_SPORT_ID = 45
49
+ BOBSLEIGH_SPORT_ID = 46
50
+ FIGURE_SKATING_SPORT_ID = 47
51
+ FREESTYLE_SKIING_SPORT_ID = 48
52
+ LUGE_SPORT_ID = 49
53
+ NORDIC_COMBINED_SPORT_ID = 50
54
+ SHORT_TRACK_SPORT_ID = 51
55
+ SKELETON_SPORT_ID = 52
56
+ SNOWBOARDING_SPORT_ID = 53
57
+ SPEED_SKATING_SPORT_ID = 54
58
+ OLYMPICS_SPORT_ID = 55
59
+ ATHLETICS_SPORT_ID = 56
60
+ CROSSFIT_SPORT_ID = 57
61
+ ENTERTAINMENT_SPORT_ID = 58
62
+ ARCHERY_SPORT_ID = 59
63
+ DRONE_RACING_SPORT_ID = 60
64
+ POKER_SPORT_ID = 62
65
+ MOTORSPORT_SPORT_ID = 63
66
+ SIMULATED_GAMES_SPORT_ID = 64
67
+ SUMO_SPORT_ID = 65
68
+
69
+
70
+ BADMINTON_SPORT_ID = Sport.BADMINTON_SPORT_ID
71
+ BANDY_SPORT_ID = Sport.BANDY_SPORT_ID
72
+ BASEBALL_SPORT_ID = Sport.BASEBALL_SPORT_ID
73
+ BASKETBALL_SPORT_ID = Sport.BASKETBALL_SPORT_ID
74
+ BEACH_VOLLEYBALL_SPORT_ID = Sport.BEACH_VOLLEYBALL_SPORT_ID
75
+ BOXING_SPORT_ID = Sport.BOXING_SPORT_ID
76
+ CHESS_SPORT_ID = Sport.CHESS_SPORT_ID
77
+ CRICKET_SPORT_ID = Sport.CRICKET_SPORT_ID
78
+ CURLING_SPORT_ID = Sport.CURLING_SPORT_ID
79
+ DARTS_SPORT_ID = Sport.DARTS_SPORT_ID
80
+ FIELD_HOCKEY_SPORT_ID = Sport.FIELD_HOCKEY_SPORT_ID
81
+ FLOORBALL_SPORT_ID = Sport.FLOORBALL_SPORT_ID
82
+ FOOTBALL_SPORT_ID = Sport.FOOTBALL_SPORT_ID
83
+ FUTSAL_SPORT_ID = Sport.FUTSAL_SPORT_ID
84
+ GOLF_SPORT_ID = Sport.GOLF_SPORT_ID
85
+ HANDBALL_SPORT_ID = Sport.HANDBALL_SPORT_ID
86
+ HOCKEY_SPORT_ID = Sport.HOCKEY_SPORT_ID
87
+ HORSE_RACING_SPECIALS_SPORT_ID = Sport.HORSE_RACING_SPECIALS_SPORT_ID
88
+ LACROSSE_SPORT_ID = Sport.LACROSSE_SPORT_ID
89
+ MMA_SPORT_ID = Sport.MMA_SPORT_ID
90
+ OTHER_SPORTS_SPORT_ID = Sport.OTHER_SPORTS_SPORT_ID
91
+ POLITICS_SPORT_ID = Sport.POLITICS_SPORT_ID
92
+ RUGBY_LEAGUE_SPORT_ID = Sport.RUGBY_LEAGUE_SPORT_ID
93
+ RUGBY_UNION_SPORT_ID = Sport.RUGBY_UNION_SPORT_ID
94
+ SNOOKER_SPORT_ID = Sport.SNOOKER_SPORT_ID
95
+ SOCCER_SPORT_ID = Sport.SOCCER_SPORT_ID
96
+ SOFTBALL_SPORT_ID = Sport.SOFTBALL_SPORT_ID
97
+ SQUASH_SPORT_ID = Sport.SQUASH_SPORT_ID
98
+ TABLE_TENNIS_SPORT_ID = Sport.TABLE_TENNIS_SPORT_ID
99
+ TENNIS_SPORT_ID = Sport.TENNIS_SPORT_ID
100
+ VOLLEYBALL_SPORT_ID = Sport.VOLLEYBALL_SPORT_ID
101
+ WATER_POLO_SPORT_ID = Sport.WATER_POLO_SPORT_ID
102
+ PADEL_TENNIS_SPORT_ID = Sport.PADEL_TENNIS_SPORT_ID
103
+ AUSSIE_RULES_SPORT_ID = Sport.AUSSIE_RULES_SPORT_ID
104
+ ALPINE_SKIING_SPORT_ID = Sport.ALPINE_SKIING_SPORT_ID
105
+ BIATHLON_SPORT_ID = Sport.BIATHLON_SPORT_ID
106
+ SKI_JUMPING_SPORT_ID = Sport.SKI_JUMPING_SPORT_ID
107
+ CROSS_COUNTRY_SPORT_ID = Sport.CROSS_COUNTRY_SPORT_ID
108
+ FORMULA1_SPORT_ID = Sport.FORMULA1_SPORT_ID
109
+ CYCLING_SPORT_ID = Sport.CYCLING_SPORT_ID
110
+ BOBSLEIGH_SPORT_ID = Sport.BOBSLEIGH_SPORT_ID
111
+ FIGURE_SKATING_SPORT_ID = Sport.FIGURE_SKATING_SPORT_ID
112
+ FREESTYLE_SKIING_SPORT_ID = Sport.FREESTYLE_SKIING_SPORT_ID
113
+ LUGE_SPORT_ID = Sport.LUGE_SPORT_ID
114
+ NORDIC_COMBINED_SPORT_ID = Sport.NORDIC_COMBINED_SPORT_ID
115
+ SHORT_TRACK_SPORT_ID = Sport.SHORT_TRACK_SPORT_ID
116
+ SKELETON_SPORT_ID = Sport.SKELETON_SPORT_ID
117
+ SNOWBOARDING_SPORT_ID = Sport.SNOWBOARDING_SPORT_ID
118
+ SPEED_SKATING_SPORT_ID = Sport.SPEED_SKATING_SPORT_ID
119
+ OLYMPICS_SPORT_ID = Sport.OLYMPICS_SPORT_ID
120
+ ATHLETICS_SPORT_ID = Sport.ATHLETICS_SPORT_ID
121
+ CROSSFIT_SPORT_ID = Sport.CROSSFIT_SPORT_ID
122
+ ENTERTAINMENT_SPORT_ID = Sport.ENTERTAINMENT_SPORT_ID
123
+ ARCHERY_SPORT_ID = Sport.ARCHERY_SPORT_ID
124
+ DRONE_RACING_SPORT_ID = Sport.DRONE_RACING_SPORT_ID
125
+ POKER_SPORT_ID = Sport.POKER_SPORT_ID
126
+ MOTORSPORT_SPORT_ID = Sport.MOTORSPORT_SPORT_ID
127
+ SIMULATED_GAMES_SPORT_ID = Sport.SIMULATED_GAMES_SPORT_ID
128
+ SUMO_SPORT_ID = Sport.SUMO_SPORT_ID
129
+
130
+
131
+ SPORT_ID_TO_NAME: dict[Sport, str] = {
132
+ Sport.BADMINTON_SPORT_ID: "Badminton",
133
+ Sport.BANDY_SPORT_ID: "Bandy",
134
+ Sport.BASEBALL_SPORT_ID: "Baseball",
135
+ Sport.BASKETBALL_SPORT_ID: "Basketball",
136
+ Sport.BEACH_VOLLEYBALL_SPORT_ID: "Beach Volleyball",
137
+ Sport.BOXING_SPORT_ID: "Boxing",
138
+ Sport.CHESS_SPORT_ID: "Chess",
139
+ Sport.CRICKET_SPORT_ID: "Cricket",
140
+ Sport.CURLING_SPORT_ID: "Curling",
141
+ Sport.DARTS_SPORT_ID: "Darts",
142
+ Sport.FIELD_HOCKEY_SPORT_ID: "Field Hockey",
143
+ Sport.FLOORBALL_SPORT_ID: "Floorball",
144
+ Sport.FOOTBALL_SPORT_ID: "Football",
145
+ Sport.FUTSAL_SPORT_ID: "Futsal",
146
+ Sport.GOLF_SPORT_ID: "Golf",
147
+ Sport.HANDBALL_SPORT_ID: "Handball",
148
+ Sport.HOCKEY_SPORT_ID: "Hockey",
149
+ Sport.HORSE_RACING_SPECIALS_SPORT_ID: "Horse Racing Specials",
150
+ Sport.LACROSSE_SPORT_ID: "Lacrosse",
151
+ Sport.MMA_SPORT_ID: "Mixed Martial Arts",
152
+ Sport.OTHER_SPORTS_SPORT_ID: "Other Sports",
153
+ Sport.POLITICS_SPORT_ID: "Politics",
154
+ Sport.RUGBY_LEAGUE_SPORT_ID: "Rugby League",
155
+ Sport.RUGBY_UNION_SPORT_ID: "Rugby Union",
156
+ Sport.SNOOKER_SPORT_ID: "Snooker",
157
+ Sport.SOCCER_SPORT_ID: "Soccer",
158
+ Sport.SOFTBALL_SPORT_ID: "Softball",
159
+ Sport.SQUASH_SPORT_ID: "Squash",
160
+ Sport.TABLE_TENNIS_SPORT_ID: "Table Tennis",
161
+ Sport.TENNIS_SPORT_ID: "Tennis",
162
+ Sport.VOLLEYBALL_SPORT_ID: "Volleyball",
163
+ Sport.WATER_POLO_SPORT_ID: "Water Polo",
164
+ Sport.PADEL_TENNIS_SPORT_ID: "Padel Tennis",
165
+ Sport.AUSSIE_RULES_SPORT_ID: "Aussie Rules",
166
+ Sport.ALPINE_SKIING_SPORT_ID: "Alpine Skiing",
167
+ Sport.BIATHLON_SPORT_ID: "Biathlon",
168
+ Sport.SKI_JUMPING_SPORT_ID: "Ski Jumping",
169
+ Sport.CROSS_COUNTRY_SPORT_ID: "Cross Country",
170
+ Sport.FORMULA1_SPORT_ID: "Formula 1",
171
+ Sport.CYCLING_SPORT_ID: "Cycling",
172
+ Sport.BOBSLEIGH_SPORT_ID: "Bobsleigh",
173
+ Sport.FIGURE_SKATING_SPORT_ID: "Figure Skating",
174
+ Sport.FREESTYLE_SKIING_SPORT_ID: "Freestyle Skiing",
175
+ Sport.LUGE_SPORT_ID: "Luge",
176
+ Sport.NORDIC_COMBINED_SPORT_ID: "Nordic Combined",
177
+ Sport.SHORT_TRACK_SPORT_ID: "Short Track",
178
+ Sport.SKELETON_SPORT_ID: "Skeleton",
179
+ Sport.SNOWBOARDING_SPORT_ID: "Snow Boarding",
180
+ Sport.SPEED_SKATING_SPORT_ID: "Speed Skating",
181
+ Sport.OLYMPICS_SPORT_ID: "Olympics",
182
+ Sport.ATHLETICS_SPORT_ID: "Athletics",
183
+ Sport.CROSSFIT_SPORT_ID: "Crossfit",
184
+ Sport.ENTERTAINMENT_SPORT_ID: "Entertainment",
185
+ Sport.ARCHERY_SPORT_ID: "Archery",
186
+ Sport.DRONE_RACING_SPORT_ID: "Drone Racing",
187
+ Sport.POKER_SPORT_ID: "Poker",
188
+ Sport.MOTORSPORT_SPORT_ID: "Motorsport",
189
+ Sport.SIMULATED_GAMES_SPORT_ID: "Simulated Games",
190
+ Sport.SUMO_SPORT_ID: "Sumo",
191
+ }
192
+
193
+
194
+ __all__ = [
195
+ "Sport",
196
+ "SPORT_ID_TO_NAME",
197
+ "BADMINTON_SPORT_ID",
198
+ "BANDY_SPORT_ID",
199
+ "BASEBALL_SPORT_ID",
200
+ "BASKETBALL_SPORT_ID",
201
+ "BEACH_VOLLEYBALL_SPORT_ID",
202
+ "BOXING_SPORT_ID",
203
+ "CHESS_SPORT_ID",
204
+ "CRICKET_SPORT_ID",
205
+ "CURLING_SPORT_ID",
206
+ "DARTS_SPORT_ID",
207
+ "FIELD_HOCKEY_SPORT_ID",
208
+ "FLOORBALL_SPORT_ID",
209
+ "FOOTBALL_SPORT_ID",
210
+ "FUTSAL_SPORT_ID",
211
+ "GOLF_SPORT_ID",
212
+ "HANDBALL_SPORT_ID",
213
+ "HOCKEY_SPORT_ID",
214
+ "HORSE_RACING_SPECIALS_SPORT_ID",
215
+ "LACROSSE_SPORT_ID",
216
+ "MMA_SPORT_ID",
217
+ "OTHER_SPORTS_SPORT_ID",
218
+ "POLITICS_SPORT_ID",
219
+ "RUGBY_LEAGUE_SPORT_ID",
220
+ "RUGBY_UNION_SPORT_ID",
221
+ "SNOOKER_SPORT_ID",
222
+ "SOCCER_SPORT_ID",
223
+ "SOFTBALL_SPORT_ID",
224
+ "SQUASH_SPORT_ID",
225
+ "TABLE_TENNIS_SPORT_ID",
226
+ "TENNIS_SPORT_ID",
227
+ "VOLLEYBALL_SPORT_ID",
228
+ "WATER_POLO_SPORT_ID",
229
+ "PADEL_TENNIS_SPORT_ID",
230
+ "AUSSIE_RULES_SPORT_ID",
231
+ "ALPINE_SKIING_SPORT_ID",
232
+ "BIATHLON_SPORT_ID",
233
+ "SKI_JUMPING_SPORT_ID",
234
+ "CROSS_COUNTRY_SPORT_ID",
235
+ "FORMULA1_SPORT_ID",
236
+ "CYCLING_SPORT_ID",
237
+ "BOBSLEIGH_SPORT_ID",
238
+ "FIGURE_SKATING_SPORT_ID",
239
+ "FREESTYLE_SKIING_SPORT_ID",
240
+ "LUGE_SPORT_ID",
241
+ "NORDIC_COMBINED_SPORT_ID",
242
+ "SHORT_TRACK_SPORT_ID",
243
+ "SKELETON_SPORT_ID",
244
+ "SNOWBOARDING_SPORT_ID",
245
+ "SPEED_SKATING_SPORT_ID",
246
+ "OLYMPICS_SPORT_ID",
247
+ "ATHLETICS_SPORT_ID",
248
+ "CROSSFIT_SPORT_ID",
249
+ "ENTERTAINMENT_SPORT_ID",
250
+ "ARCHERY_SPORT_ID",
251
+ "DRONE_RACING_SPORT_ID",
252
+ "POKER_SPORT_ID",
253
+ "MOTORSPORT_SPORT_ID",
254
+ "SIMULATED_GAMES_SPORT_ID",
255
+ "SUMO_SPORT_ID",
256
+ ]
@@ -0,0 +1,6 @@
1
+ from typing import TypedDict
2
+
3
+
4
+ class EventInfo(TypedDict):
5
+ leagueId: int
6
+ eventId: int
ps3838api/py.typed ADDED
File without changes
ps3838api/tank.py ADDED
@@ -0,0 +1,93 @@
1
+ """Centralised fixtures/odds caching that respects PS3838 rate‑limits.
2
+
3
+ The core idea is identical for both resources:
4
+ • ≥ 60 s since previous call → **snapshot** (full refresh)
5
+ • 5–59 s → **delta** (incremental update, merged into cache)
6
+ • < 5 s → **use in‑memory cache**, no API hit
7
+
8
+ Odds were already following this contract. Fixtures now do too.
9
+ Additionally, fixtures are **no longer persisted** as one huge
10
+ ``fixtures.json`` file. Every API response (snapshot *and* delta)
11
+ gets stored verbatim in *temp/responses/* for replay/debugging just
12
+ like odds. If a full history is ever required you can reconstruct it
13
+ from those files.
14
+ """
15
+
16
+ import json
17
+ import logging
18
+ import warnings
19
+ from pathlib import Path
20
+ from time import time
21
+
22
+ from ps3838api.api.client import PinnacleClient
23
+ from ps3838api.matching import MATCHED_LEAGUES
24
+ from ps3838api.models.fixtures import FixturesResponse
25
+ from ps3838api.utils.ops import merge_fixtures
26
+
27
+ warnings.warn(
28
+ f"{__name__} is experimental, incomplete, and may change in future versions.",
29
+ UserWarning,
30
+ )
31
+
32
+
33
+ SNAPSHOT_INTERVAL = 60
34
+ DELTA_INTERVAL = 5
35
+
36
+
37
+ TOP_LEAGUES = [league["ps3838_id"] for league in MATCHED_LEAGUES if league["ps3838_id"]]
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class FixtureTank:
43
+ """Lightweight cache for Pinnacle *fixtures*.
44
+
45
+ * No big persisted file – only individual API responses are archived.
46
+ * Shares the same timing policy as :class:`OddsTank`.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ client: PinnacleClient,
52
+ league_ids: list[int] | None = None,
53
+ response_dir: Path | str | None = None, # Path("temp/responses")
54
+ ) -> None:
55
+ self.client = client
56
+ self.response_dir = Path(response_dir) if response_dir else None
57
+ # start with a fresh snapshot (fast + guarantees consistency)
58
+ self.data: FixturesResponse = client.get_fixtures(league_ids=league_ids)
59
+ self._last_call_time = time()
60
+ self._save_response(self.data, snapshot=True)
61
+
62
+ def _save_response(self, response_data: FixturesResponse, snapshot: bool) -> None:
63
+ """
64
+ Save fixture response to the temp/responses folder for future testing.
65
+ """
66
+ if not self.response_dir:
67
+ return
68
+ kind = "snapshot" if snapshot else "delta"
69
+ self.response_dir.mkdir(parents=True, exist_ok=True)
70
+ fn = self.response_dir / f"fixtures_{kind}_{int(time())}.json"
71
+ with open(fn, "w") as f:
72
+ json.dump(response_data, f, indent=4)
73
+
74
+ def update(self) -> None:
75
+ """Refresh internal cache if timing thresholds are met."""
76
+ now = time()
77
+ elapsed = now - self._last_call_time
78
+
79
+ if elapsed < DELTA_INTERVAL:
80
+ return # 💡 Too soon – use cached data
81
+
82
+ if elapsed >= SNAPSHOT_INTERVAL:
83
+ # ── Full refresh ────────────────────────────────────────────
84
+ resp = self.client.get_fixtures()
85
+ self.data = resp
86
+ self._save_response(resp, snapshot=True)
87
+ else:
88
+ # ── Incremental update ──────────────────────────────────────
89
+ delta = self.client.get_fixtures(since=self.data["last"])
90
+ self.data = merge_fixtures(self.data, delta)
91
+ self._save_response(delta, snapshot=False)
92
+
93
+ self._last_call_time = now
ps3838api/totals.py ADDED
@@ -0,0 +1,62 @@
1
+ import warnings
2
+ from typing import cast
3
+
4
+ from ps3838api.models.odds import OddsEventV3, OddsTotalV3
5
+
6
+ warnings.warn(
7
+ f"{__name__} is experimental and its interface is not stable yet.",
8
+ FutureWarning,
9
+ )
10
+
11
+
12
+ class OddsTotal(OddsTotalV3):
13
+ """Has line id"""
14
+
15
+ lineId: int
16
+
17
+
18
+ def calculate_margin(total: OddsTotalV3) -> float:
19
+ return (1 / total["over"] + 1 / total["under"]) - 1
20
+
21
+
22
+ def get_all_total_lines(
23
+ odds: OddsEventV3,
24
+ periods: list[int] = [
25
+ 0,
26
+ ],
27
+ ) -> list[OddsTotal]:
28
+ result: list[OddsTotal] = []
29
+ for period in odds["periods"]: # type: ignore
30
+ if "number" not in period:
31
+ # skip if unknown period
32
+ continue
33
+ if period["number"] not in periods:
34
+ # skip if wrong periood
35
+ continue
36
+ if "totals" not in period:
37
+ # skip if no totals in this period
38
+ continue
39
+ if "lineId" not in period:
40
+ # skip if don't have lineId
41
+ continue
42
+
43
+ lineId = period["lineId"]
44
+ maxTotal = period["maxTotal"] if "maxTotal" in period else None
45
+
46
+ for total in period["totals"]:
47
+ odds_total = cast(OddsTotal, total.copy())
48
+ odds_total["lineId"] = lineId
49
+ # each total should have lineId
50
+
51
+ if "altLineId" not in total:
52
+ if maxTotal is not None:
53
+ odds_total["max"] = maxTotal
54
+ result.append(odds_total)
55
+ return result
56
+
57
+
58
+ def get_best_total_line(odds: OddsEventV3, periods: list[int] = [0, 1]) -> OddsTotal | None:
59
+ try:
60
+ return min(get_all_total_lines(odds, periods=periods), key=calculate_margin)
61
+ except Exception:
62
+ return None
@@ -0,0 +1,90 @@
1
+ # type: ignore
2
+ import json
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from rapidfuzz import fuzz, process
7
+
8
+ from ps3838api import ROOT_MODULE_DIR
9
+
10
+
11
+ # Your threshold-based fuzzy function
12
+ def is_leagues_match(league1: str, league2: str, threshold: int = 80) -> bool:
13
+ """
14
+ Returns True if leagues are a fuzzy match with a token sort ratio >= threshold.
15
+ fuzz.token_sort_ratio() returns 0-100, so 80 means 80% similar.
16
+ """
17
+ return fuzz.token_sort_ratio(league1, league2) >= threshold
18
+
19
+
20
+ def load_json(path: str | Path) -> list[Any] | dict[str, Any]:
21
+ with open(path, "r", encoding="utf-8") as f:
22
+ return json.load(f)
23
+
24
+
25
+ def main():
26
+ betsapi_path = ROOT_MODULE_DIR / Path("out/betsapi_leagues.json")
27
+ ps3838_path = ROOT_MODULE_DIR / Path("out/ps3838_leagues.json")
28
+ output_path = ROOT_MODULE_DIR / Path("out/matched_leagues.json")
29
+
30
+ # --------------------------------------------------------------------
31
+ # Load raw data
32
+ # --------------------------------------------------------------------
33
+ betsapi_leagues = load_json(betsapi_path) # e.g. ["Premier League", "La Liga", ...]
34
+ ps3838_data = load_json(ps3838_path)
35
+ # --------------------------------------------------------------------
36
+ # Build a RapidFuzz index of PS3838 league names to do quick "best-match" lookups
37
+ # --------------------------------------------------------------------
38
+ # 1) Just keep a list of league names:
39
+ ps_names = [league["name"] for league in ps3838_data]
40
+
41
+ # 2) Also map name -> (full record) for easy ID lookup:
42
+ ps_map = {league["name"]: league for league in ps3838_data}
43
+
44
+ # --------------------------------------------------------------------
45
+ # Compare each BetsAPI league to the best PS3838 league match
46
+ # --------------------------------------------------------------------
47
+ matched = []
48
+ for betsapi_league in betsapi_leagues:
49
+ # RapidFuzz: find the single best match
50
+ # extractOne returns a tuple: (best_match_string, score, index)
51
+ best_match = process.extractOne(betsapi_league, ps_names, scorer=fuzz.token_sort_ratio)
52
+
53
+ if best_match is not None:
54
+ ps_name, score, _ = best_match
55
+ if score >= 80:
56
+ # It's a good fuzzy match
57
+ matched_league_info = {
58
+ "betsapi_league": betsapi_league,
59
+ "ps3838_league": ps_map[ps_name]["name"],
60
+ "ps3838_id": ps_map[ps_name]["id"],
61
+ }
62
+ else:
63
+ # We got a best match but it's below threshold
64
+ matched_league_info = {
65
+ "betsapi_league": betsapi_league,
66
+ "ps3838_league": None,
67
+ "ps3838_id": None,
68
+ }
69
+ else:
70
+ # No match at all
71
+ matched_league_info = {
72
+ "betsapi_league": betsapi_league,
73
+ "ps3838_league": None,
74
+ "ps3838_id": None,
75
+ }
76
+
77
+ matched.append(matched_league_info)
78
+
79
+ # --------------------------------------------------------------------
80
+ # Save output
81
+ # --------------------------------------------------------------------
82
+ output_path.parent.mkdir(parents=True, exist_ok=True)
83
+ with open(output_path, "w", encoding="utf-8") as f:
84
+ json.dump(matched, f, indent=2, ensure_ascii=False)
85
+
86
+ print(f"✅ Matching complete. Output saved to: {output_path}")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
ps3838api/utils/ops.py ADDED
@@ -0,0 +1,146 @@
1
+ import warnings
2
+
3
+ from ps3838api.models.event import NoSuchLeagueFixtures, NoSuchOddsAvailable
4
+ from ps3838api.models.fixtures import FixturesLeagueV3, FixturesResponse, FixtureV3
5
+ from ps3838api.models.odds import OddsEventV3, OddsLeagueV3, OddsResponse
6
+ from ps3838api.models.tank import EventInfo
7
+
8
+ warnings.warn(
9
+ f"{__name__} is experimental, incomplete, and may change in future versions.",
10
+ UserWarning,
11
+ )
12
+
13
+
14
+ def merge_odds_response(old: OddsResponse, new: OddsResponse) -> OddsResponse:
15
+ """
16
+ Merge a snapshot OddsResponse (old) with a delta OddsResponse (new).
17
+ - Leagues are matched by league["id"].
18
+ - Events are matched by event["id"].
19
+ - Periods are matched by period["number"].
20
+ - Any period present in 'new' entirely replaces the same period number in 'old'.
21
+ - Periods not present in 'new' remain as they were in 'old'.
22
+
23
+ Returns a merged OddsResponse that includes updated odds and periods, retaining
24
+ old entries when no changes were reported in the delta.
25
+
26
+ Based on "How to get odds changes?" from https://ps3838api.github.io/FAQs.html
27
+ """
28
+ # Index the old leagues by their IDs
29
+ league_index: dict[int, OddsLeagueV3] = {league["id"]: league for league in old.get("leagues", [])}
30
+
31
+ # Loop through the new leagues
32
+ for new_league in new.get("leagues", []):
33
+ lid = new_league["id"]
34
+
35
+ # If it's an entirely new league, just store it
36
+ if lid not in league_index:
37
+ league_index[lid] = new_league
38
+ continue
39
+
40
+ # Otherwise merge it with the existing league
41
+ old_league = league_index[lid]
42
+ old_event_index = {event["id"]: event for event in old_league.get("events", [])}
43
+
44
+ # Loop through the new events
45
+ for new_event in new_league.get("events", []):
46
+ eid = new_event["id"]
47
+
48
+ # If it's an entirely new event, just store it
49
+ if eid not in old_event_index:
50
+ old_event_index[eid] = new_event
51
+ continue
52
+
53
+ # Otherwise, merge with the existing event
54
+ old_event = old_event_index[eid]
55
+
56
+ # Periods: build an index by 'number' from the old event
57
+ old_period_index = {p["number"]: p for p in old_event.get("periods", []) if "number" in p}
58
+
59
+ # Take all the new event's periods and override or insert them by 'number'
60
+ for new_period in new_event.get("periods", []):
61
+ if "number" not in new_period:
62
+ continue
63
+ old_period_index[new_period["number"]] = new_period
64
+
65
+ # Merge top-level fields: new event fields override old ones
66
+ merged_event = old_event.copy()
67
+ merged_event.update(new_event)
68
+
69
+ # Rebuild the merged_event's periods from the updated dictionary
70
+ merged_event["periods"] = list(old_period_index.values())
71
+
72
+ # Store back in the event index
73
+ old_event_index[eid] = merged_event
74
+
75
+ # Rebuild league's events list from the merged event index
76
+ old_league["events"] = list(old_event_index.values())
77
+
78
+ return {
79
+ "sportId": new.get("sportId", old["sportId"]),
80
+ # Always take the latest `last` timestamp from the new (delta) response
81
+ "last": new["last"],
82
+ # Rebuild leagues list
83
+ "leagues": list(league_index.values()),
84
+ }
85
+
86
+
87
+ def merge_fixtures(old: FixturesResponse, new: FixturesResponse) -> FixturesResponse:
88
+ league_index: dict[int, FixturesLeagueV3] = {league["id"]: league for league in old.get("league", [])}
89
+
90
+ for new_league in new.get("league", []):
91
+ lid = new_league["id"]
92
+ if lid in league_index:
93
+ old_events = {e["id"]: e for e in league_index[lid]["events"]}
94
+ for event in new_league["events"]:
95
+ old_events[event["id"]] = event # override or insert
96
+ league_index[lid]["events"] = list(old_events.values())
97
+ else:
98
+ league_index[lid] = new_league # new league entirely
99
+ return {
100
+ "sportId": new.get("sportId", old["sportId"]),
101
+ "last": new["last"],
102
+ "league": list(league_index.values()),
103
+ }
104
+
105
+
106
+ def find_league_in_fixtures(
107
+ fixtures: FixturesResponse, league: str, league_id: int
108
+ ) -> FixturesLeagueV3 | NoSuchLeagueFixtures:
109
+ for leagueV3 in fixtures["league"]:
110
+ if leagueV3["id"] == league_id:
111
+ return leagueV3
112
+ else:
113
+ return NoSuchLeagueFixtures(league)
114
+
115
+
116
+ def find_fixtureV3_in_league(leagueV3: FixturesLeagueV3, event_id: int) -> FixtureV3:
117
+ for eventV3 in leagueV3["events"]:
118
+ if eventV3["id"] == event_id:
119
+ return eventV3
120
+ raise ValueError("No such event")
121
+
122
+
123
+ def filter_odds(
124
+ odds: OddsResponse, event_id: int, league_id: int | None = None
125
+ ) -> OddsEventV3 | NoSuchOddsAvailable:
126
+ """passing `league_id` makes search in json faster"""
127
+ for league in odds["leagues"]:
128
+ if league_id and league_id != league["id"]:
129
+ continue
130
+ for fixture in league["events"]:
131
+ if fixture["id"] == event_id:
132
+ return fixture
133
+ return NoSuchOddsAvailable(event_id)
134
+
135
+
136
+ def normalize_to_set(name: str) -> set[str]:
137
+ return set(name.replace(" II", " 2").replace(" I", "").lower().replace("-", " ").split())
138
+
139
+
140
+ def find_event_by_id(fixtures: FixturesResponse, event: EventInfo) -> FixtureV3 | None:
141
+ for leagueV3 in fixtures["league"]:
142
+ if leagueV3["id"] == event["leagueId"]:
143
+ for fixtureV3 in leagueV3["events"]:
144
+ if fixtureV3["id"] == event["eventId"]:
145
+ return fixtureV3
146
+ return None