Align balldontlie OpenAPI integration and clarify search metric semantics

This commit is contained in:
Alfredo Di Stasio
2026-03-12 16:37:02 +01:00
parent c9dd10a438
commit dac63f9148
16 changed files with 1562 additions and 82 deletions

View File

@ -1,5 +1,4 @@
import logging
from itertools import islice
from django.conf import settings
@ -30,6 +29,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
"""HTTP MVP adapter for balldontlie (NBA-centric data source)."""
namespace = "balldontlie"
nba_prefix = "/nba/v1"
def __init__(self, client: BalldontlieClient | None = None):
self.client = client or BalldontlieClient()
@ -38,46 +38,23 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
def configured_seasons(self) -> list[int]:
return settings.PROVIDER_BALLDONTLIE_SEASONS
@staticmethod
def _chunked(values: list[int], size: int):
iterator = iter(values)
while True:
chunk = list(islice(iterator, size))
if not chunk:
return
yield chunk
def _fetch_game_ids(self) -> list[int]:
game_ids: set[int] = set()
for season in self.configured_seasons:
rows = self.client.list_paginated(
"games",
params={"seasons[]": season},
per_page=settings.PROVIDER_BALLDONTLIE_GAMES_PER_PAGE,
page_limit=settings.PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT,
)
for row in rows:
game_id = row.get("id")
if isinstance(game_id, int):
game_ids.add(game_id)
return sorted(game_ids)
def _api_path(self, path: str) -> str:
# Support both base URL variants:
# - https://api.balldontlie.io
# - https://api.balldontlie.io/nba/v1
base = getattr(self.client, "base_url", "").rstrip("/")
if base.endswith("/nba/v1"):
return path.lstrip("/")
return f"{self.nba_prefix}/{path.lstrip('/')}"
def _fetch_stats_rows(self) -> list[dict]:
game_ids = self._fetch_game_ids()
if not game_ids:
logger.info(
"provider_stats_skipped_no_games",
extra={"provider": self.namespace, "seasons": self.configured_seasons},
)
return []
all_rows: list[dict] = []
try:
# Use game_ids[] query as documented in balldontlie getting-started flow.
for game_id_chunk in self._chunked(game_ids, 25):
# OpenAPI supports seasons[] directly for /nba/v1/stats.
for season in self.configured_seasons:
rows = self.client.list_paginated(
"stats",
params={"game_ids[]": game_id_chunk},
self._api_path("stats"),
params={"seasons[]": season},
per_page=settings.PROVIDER_BALLDONTLIE_STATS_PER_PAGE,
page_limit=settings.PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT,
)
@ -101,7 +78,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[PlayerPayload]:
params = {"search": query} if query else None
rows = self.client.list_paginated(
"players",
self._api_path("players"),
params=params,
per_page=min(limit, settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE),
page_limit=1,
@ -113,7 +90,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
if not external_player_id.startswith("player-"):
return None
player_id = external_player_id.replace("player-", "", 1)
payload = self.client.get_json(f"players/{player_id}")
payload = self.client.get_json(self._api_path(f"players/{player_id}"))
data = payload.get("data")
if not isinstance(data, dict):
return None
@ -122,7 +99,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
def fetch_players(self) -> list[PlayerPayload]:
rows = self.client.list_paginated(
"players",
self._api_path("players"),
per_page=settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE,
page_limit=settings.PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT,
)
@ -132,7 +109,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
return map_competitions()
def fetch_teams(self) -> list[TeamPayload]:
payload = self.client.get_json("teams")
payload = self.client.get_json(self._api_path("teams"))
rows = payload.get("data") or []
return map_teams(rows if isinstance(rows, list) else [])

View File

@ -123,9 +123,6 @@ class BalldontlieClient:
request_query["per_page"] = per_page
if cursor is not None:
request_query["cursor"] = cursor
else:
# Keep backwards compatibility for endpoints still supporting page-based pagination.
request_query["page"] = page
payload = self.get_json(path, params=request_query)
data = payload.get("data") or []
@ -139,11 +136,6 @@ class BalldontlieClient:
page += 1
continue
next_page = meta.get("next_page")
if next_page:
page = int(next_page)
continue
break
return rows