krz/nba-scores
A CLI/TUI app for NBA scores.
clone: git clone https://gitbay.org/krz/nba-scores.git
1"""
2Fetches and formats the NBA playoff bracket.
3"""
4
5from __future__ import annotations
6
7import json
8import re
9from collections import defaultdict
10
11from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries
12from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog
13from nba_api.stats.static import teams
14
15BOLD = "\033[1m"
16END = "\033[0m"
17GREEN = "\033[32m"
18RED = "\033[91m"
19YELLOW = "\033[33m"
20CYAN = "\033[36m"
21ANSI_RE = re.compile(r"\033\[[0-9;]*m")
22
23ROUND_LABELS = {
24 1: "First Round",
25 2: "Conference Semifinals",
26 3: "Conference Finals",
27 4: "NBA Finals",
28}
29
30
31def fetch_bracket() -> dict:
32 """
33 Fetch playoff series and completed playoff games.
34
35 CommonPlayoffSeries provides the bracket's scheduled series and games. The
36 playoff game log is used to compute each series record from completed games.
37 """
38 series_endpoint = CommonPlayoffSeries()
39 game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs")
40 return {
41 "series": json.loads(series_endpoint.get_json()),
42 "game_log": json.loads(game_log_endpoint.get_json()),
43 }
44
45
46def get_bracket_table(data: dict) -> str:
47 """Return a formatted playoff bracket."""
48 result_sets = data["series"]["resultSets"]
49 series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None)
50 if series_set is None or not series_set["rowSet"]:
51 return "No playoff bracket data available."
52
53 headers = series_set["headers"]
54 idx = {header: i for i, header in enumerate(headers)}
55 team_map = _team_map()
56 series_games = _group_series(series_set["rowSet"], idx)
57 series_wins = _series_wins(data["game_log"], series_games)
58 summaries = _series_summaries(team_map, series_games, series_wins)
59
60 return "\n".join(
61 [
62 _center(f"{BOLD}NBA Playoff Bracket{END}", 74),
63 (
64 f"{GREEN}* Advanced{END} {RED}x Eliminated{END} "
65 f"{CYAN}> Series lead{END} {YELLOW}! Can clinch next win{END}"
66 ),
67 "",
68 _render_conference("Western", summaries),
69 "",
70 _render_finals(summaries),
71 "",
72 _render_conference("Eastern", summaries),
73 ]
74 )
75
76
77def _team_map() -> dict:
78 return {
79 team["id"]: {
80 "abbr": team["abbreviation"],
81 "name": f"{team['city']} {team['nickname']}",
82 }
83 for team in teams.get_teams()
84 }
85
86
87def _group_series(rows: list, idx: dict) -> dict:
88 series_games = defaultdict(list)
89 for row in rows:
90 series_id = row[idx["SERIES_ID"]]
91 series_games[series_id].append(
92 {
93 "game_id": row[idx["GAME_ID"]],
94 "game_num": row[idx["GAME_NUM"]],
95 "home_team_id": row[idx["HOME_TEAM_ID"]],
96 "visitor_team_id": row[idx["VISITOR_TEAM_ID"]],
97 }
98 )
99
100 return {
101 series_id: sorted(games, key=lambda game: game["game_num"])
102 for series_id, games in series_games.items()
103 }
104
105
106def _series_wins(game_log: dict, series_games: dict) -> dict:
107 game_to_series = {
108 game["game_id"]: series_id
109 for series_id, games in series_games.items()
110 for game in games
111 }
112 wins = defaultdict(lambda: defaultdict(int))
113
114 for series_id in series_games:
115 wins[series_id]["completed_games"] = set()
116
117 result_set = game_log["resultSets"][0]
118 idx = {header: i for i, header in enumerate(result_set["headers"])}
119 for row in result_set["rowSet"]:
120 game_id = row[idx["GAME_ID"]]
121 series_id = game_to_series.get(game_id)
122 if series_id is None or row[idx["WL"]] != "W":
123 continue
124
125 team_id = row[idx["TEAM_ID"]]
126 wins[series_id][team_id] += 1
127 wins[series_id]["completed_games"].add(game_id)
128
129 return wins
130
131
132def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict:
133 summaries = {}
134 for series_id, games in series_games.items():
135 first_game = games[0]
136 home_id = first_game["home_team_id"]
137 visitor_id = first_game["visitor_team_id"]
138 wins = series_wins[series_id]
139 home_wins = wins.get(home_id, 0)
140 visitor_wins = wins.get(visitor_id, 0)
141
142 summaries[series_id] = {
143 "conference": _conference(series_id),
144 "round": _round_number(series_id),
145 "slot": _series_slot(series_id),
146 "home_id": home_id,
147 "visitor_id": visitor_id,
148 "home_abbr": _abbr(team_map, home_id),
149 "visitor_abbr": _abbr(team_map, visitor_id),
150 "home_wins": home_wins,
151 "visitor_wins": visitor_wins,
152 "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins),
153 }
154 return summaries
155
156
157def _render_conference(conference: str, summaries: dict) -> str:
158 first_round = _conference_round(summaries, conference, 1)
159 semifinals = _conference_round(summaries, conference, 2)
160 finals = _conference_round(summaries, conference, 3)
161 final = finals[0] if finals else None
162
163 lines = [
164 f"{BOLD}{conference.upper()} CONFERENCE{END}",
165 "First Round Semifinals Conference Finals",
166 ]
167
168 lines.extend(
169 [
170 f"{_series_box(_slot(first_round, 0), 24)} ┐",
171 f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)} ┐",
172 f"{_series_box(_slot(first_round, 1), 24)} ┘ {_blank(24)} │",
173 f"{_blank(24)} {_blank(24)} ├── {_series_box(final, 24)}",
174 f"{_series_box(_slot(first_round, 2), 24)} ┐ {_blank(24)} │",
175 f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)} ┘",
176 f"{_series_box(_slot(first_round, 3), 24)} ┘",
177 ]
178 )
179
180 return "\n".join(lines)
181
182
183def _render_finals(summaries: dict) -> str:
184 finals = [summary for summary in summaries.values() if summary["round"] == 4]
185 final = finals[0] if finals else None
186
187 lines = [
188 f"{BOLD}NBA FINALS{END}",
189 "West Champion East Champion",
190 f"{_series_box(final, 24)}",
191 ]
192 if final is None:
193 lines.append("Winner TBD")
194 elif final["winner"]:
195 lines.append(f"{BOLD}{GREEN}{final['winner']} wins the Finals{END}")
196 return "\n".join(lines)
197
198
199def _conference_round(summaries: dict, conference: str, round_number: int) -> list:
200 return sorted(
201 [
202 summary
203 for summary in summaries.values()
204 if summary["conference"] == conference[:4]
205 and summary["round"] == round_number
206 ],
207 key=lambda summary: summary["slot"],
208 )
209
210
211def _slot(items: list, idx: int) -> dict | None:
212 return items[idx] if idx < len(items) else None
213
214
215def _series_box(summary: dict | None, width: int) -> str:
216 if summary is None:
217 return _blank(width, "TBD")
218
219 visitor = _team_line(
220 summary["visitor_abbr"],
221 summary["visitor_wins"],
222 summary["home_wins"],
223 summary["winner"] == summary["visitor_abbr"],
224 summary["winner"] == summary["home_abbr"],
225 )
226 home = _team_line(
227 summary["home_abbr"],
228 summary["home_wins"],
229 summary["visitor_wins"],
230 summary["winner"] == summary["home_abbr"],
231 summary["winner"] == summary["visitor_abbr"],
232 )
233 return _pad_ansi(f"{visitor} / {home}", width)
234
235
236def _team_line(
237 abbr: str,
238 wins: int,
239 opponent_wins: int,
240 won_series: bool,
241 lost_series: bool,
242) -> str:
243 label = f"{abbr} {wins}"
244 if won_series:
245 return f"{BOLD}{GREEN}{label}*{END}"
246 if lost_series:
247 return f"{RED}{label}x{END}"
248 if wins == 3:
249 return f"{BOLD}{YELLOW}{label}!{END}"
250 if wins > opponent_wins:
251 return f"{CYAN}{label}>{END}"
252 return label
253
254
255def _winner(
256 team_map: dict,
257 home_id: int,
258 visitor_id: int,
259 home_wins: int,
260 visitor_wins: int,
261) -> str:
262 if home_wins >= 4:
263 return _abbr(team_map, home_id)
264 if visitor_wins >= 4:
265 return _abbr(team_map, visitor_id)
266 return ""
267
268
269def _blank(width: int, text: str = "") -> str:
270 return text.ljust(width)
271
272
273def _center(text: str, width: int) -> str:
274 return text.center(width)
275
276
277def _pad_ansi(text: str, width: int) -> str:
278 visible_length = len(ANSI_RE.sub("", text))
279 return text + " " * max(width - visible_length, 0)
280
281
282def _round_number(series_id: str) -> int:
283 try:
284 return int(series_id[-3:-1])
285 except ValueError:
286 return 0
287
288
289def _series_slot(series_id: str) -> int:
290 try:
291 return int(series_id[-1])
292 except ValueError:
293 return 0
294
295
296def _conference(series_id: str) -> str:
297 round_number = _round_number(series_id)
298 slot = _series_slot(series_id)
299
300 if round_number == 4:
301 return "NBA"
302 if round_number == 3:
303 return "East" if slot == 0 else "West"
304 if round_number == 2:
305 return "East" if slot <= 1 else "West"
306 if round_number == 1:
307 return "East" if slot <= 3 else "West"
308 return ""
309
310
311def _abbr(team_map: dict, team_id: int) -> str:
312 return team_map.get(team_id, {}).get("abbr", str(team_id))