feat(worldcup): enhance scoreboard fetching with date range support

- Added new utility functions `espn_dates_for_local_day` and `filter_events_local_day` to handle local timezone date calculations and event filtering.
- Updated `ESPNClient` to support optional date parameters in `fetch_scoreboard_with_calendar` and `fetch_match_states` methods, allowing for more precise data retrieval.
- Modified `WorldCupCommand` and `WorldCupLiveService` to utilize the new date range functionality, ensuring accurate event reporting based on local time.
- Implemented unit tests to verify the correct behavior of date handling and event filtering in various scenarios.
This commit is contained in:
agessaman committed 2026-06-21 10:34:28 -07:00
1 parent f7ec71a114
commit 5aa84c4ffb
7 files changed
+205 -13

No files matched your search

+54 -7
View File
@@ -33,6 +33,37 @@ class ESPNClient:
self.session = aiohttp.ClientSession(timeout=self.timeout)
return self.session
@staticmethod
def _scoreboard_url(
sport: str,
league: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
cache_bust: bool = False,
) -> str:
url = f"{ESPNClient.BASE_URL}/{sport}/{league}/scoreboard"
params: list[str] = []
if start_date and end_date:
params.append(f"dates={start_date}-{end_date}")
if cache_bust:
params.append(f"_={int(time.time() * 1000)}")
if params:
url += "?" + "&".join(params)
return url
@staticmethod
def _event_timestamp(event: dict) -> Optional[float]:
date_str = event.get('date', '')
if not date_str:
return None
try:
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
except (TypeError, ValueError):
return None
async def fetch_scoreboard(self, sport: str, league: str) -> list[dict]:
"""Fetch and parse scoreboard data for a league"""
url = f"{self.BASE_URL}/{sport}/{league}/scoreboard"
@@ -53,7 +84,13 @@ class ESPNClient:
self.logger.error(f"ESPN fetch_scoreboard error for {sport}/{league}: {e}")
return []
async def fetch_scoreboard_with_calendar(self, sport: str, league: str) -> Optional[dict]:
async def fetch_scoreboard_with_calendar(
self,
sport: str,
league: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> Optional[dict]:
"""Fetch scoreboard plus tournament calendar/league metadata.
Returns a dict with:
@@ -64,8 +101,10 @@ class ESPNClient:
Used by the World Cup command to determine whether a tournament is in season
and to resolve nation names when standings are unavailable. Returns None on error.
Optional start_date/end_date are YYYYMMDD strings for the dated scoreboard endpoint.
"""
url = f"{self.BASE_URL}/{sport}/{league}/scoreboard"
url = self._scoreboard_url(sport, league, start_date, end_date)
try:
session = await self._get_session()
async with session.get(url) as response:
@@ -153,11 +192,18 @@ class ESPNClient:
except (TypeError, ValueError):
return 0
async def fetch_match_states(self, sport: str, league: str, cache_bust: bool = False) -> list[dict]:
async def fetch_match_states(
self,
sport: str,
league: str,
cache_bust: bool = False,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> list[dict]:
"""Return current per-match live state for the scoreboard (today's matches).
Each item: {id, home_id, away_id, home_name, away_name, home_score, away_score,
status, clock, home_pen, away_pen, goals, cards}. Names are full team display
status, clock, home_pen, away_pen, goals, cards, event_timestamp}. Names are full team display
names. Penalty fields are None unless a shootout score is present. ``goals`` is the
chronological list of scoring plays, each {clock, scorer, team_id, own_goal,
penalty, kind} (kind is 'header'/'volley'/''; penalty shootout kicks excluded).
@@ -167,10 +213,10 @@ class ESPNClient:
cache_bust appends a unique query param to bypass ESPN's edge cache, used when a
fastcast push signals a change so the REST snapshot reflects it immediately.
Optional start_date/end_date are YYYYMMDD strings for the dated scoreboard endpoint.
"""
url = f"{self.BASE_URL}/{sport}/{league}/scoreboard"
if cache_bust:
url += f"?_={int(time.time() * 1000)}"
url = self._scoreboard_url(sport, league, start_date, end_date, cache_bust=cache_bust)
try:
session = await self._get_session()
async with session.get(url) as response:
@@ -232,6 +278,7 @@ class ESPNClient:
'goals': goals,
'cards': red_cards,
'yellows': yellow_cards,
'event_timestamp': self._event_timestamp(event),
})
except Exception as e:
self.logger.warning(f"ESPN fetch_match_states: skipping malformed event {event.get('id')}: {e}")
+7 -2
View File
@@ -21,6 +21,7 @@ from ..clients.espn_client import ESPNClient
from ..clients.sports_mappings import SPORT_EMOJIS
from ..clients.worldcup_data import WorldCupData
from ..models import MeshMessage
from ..utils import espn_dates_for_local_day, filter_events_local_day, get_config_timezone
from .base_command import BaseCommand
if TYPE_CHECKING:
@@ -192,9 +193,13 @@ class WorldCupCommand(BaseCommand):
async def _handle_today(self, message: MeshMessage, league: str) -> bool:
"""Send today's scores (live first), chunked across up to 3 messages."""
# The scoreboard endpoint already carries live scores, so no refresh is needed.
data = await self.espn_client.fetch_scoreboard_with_calendar("soccer", league)
local_tz, _ = get_config_timezone(self.bot.config, self.logger)
start_date, end_date, local_start_ts, local_end_ts = espn_dates_for_local_day(local_tz)
data = await self.espn_client.fetch_scoreboard_with_calendar(
"soccer", league, start_date=start_date, end_date=end_date,
)
games = data.get("events", []) if data else []
games = filter_events_local_day(games, local_start_ts, local_end_ts)
if not games:
return await self.send_response(message, self.translate("commands.worldcup.no_games_today"))
+10 -1
View File
@@ -21,6 +21,7 @@ from typing import Any, Optional
from ..clients.espn_client import ESPNClient
from ..clients.worldcup_data import WorldCupData
from ..clients.worldcup_fastcast import WorldCupFastcastClient
from ..utils import espn_dates_for_local_day, filter_events_local_day, get_config_timezone
from .base_service import BaseServicePlugin
LIVE_STATUSES = {"STATUS_IN_PROGRESS", "STATUS_FIRST_HALF", "STATUS_SECOND_HALF", "STATUS_END_PERIOD"}
@@ -191,7 +192,15 @@ class WorldCupLiveService(BaseServicePlugin):
in_group = active.get("in_group_stage", False)
stage_label = active.get("stage_label", "")
matches = await self.espn_client.fetch_match_states("soccer", league, cache_bust=self.use_fastcast)
local_tz, _ = get_config_timezone(self.bot.config, self.logger)
start_date, end_date, local_start_ts, local_end_ts = espn_dates_for_local_day(local_tz)
matches = await self.espn_client.fetch_match_states(
"soccer", league,
cache_bust=self.use_fastcast,
start_date=start_date,
end_date=end_date,
)
matches = filter_events_local_day(matches, local_start_ts, local_end_ts)
if not matches:
return self.poll_interval_seconds
+52 -1
View File
@@ -10,7 +10,7 @@ import re
import socket
import urllib.error
import urllib.request
from datetime import datetime
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional, Union
@@ -57,6 +57,57 @@ def get_config_timezone(config: Any, logger: Optional[Any] = None) -> tuple[Any,
return (tz, "UTC")
def espn_dates_for_local_day(
local_tz: Any,
now: Optional[datetime] = None,
) -> tuple[str, str, float, float]:
"""Return ESPN scoreboard date range and local-day bounds for filtering events.
ESPN buckets scoreboard events by UTC calendar date. A single local calendar day
can span two UTC dates (e.g. 9pm PT is the next UTC day). This returns the
min/max YYYYMMDD strings to query, plus local midnight timestamps for filtering.
Returns:
(start_yyyymmdd, end_yyyymmdd, local_start_ts, local_end_ts)
"""
if now is None:
now = datetime.now(local_tz)
elif now.tzinfo is None:
if hasattr(local_tz, 'localize'):
now = local_tz.localize(now)
else:
now = now.replace(tzinfo=local_tz)
else:
now = now.astimezone(local_tz)
if hasattr(local_tz, 'localize'):
local_start = local_tz.localize(datetime(now.year, now.month, now.day))
else:
local_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
local_end = local_start + timedelta(days=1)
utc_dates = {
local_start.astimezone(timezone.utc).strftime("%Y%m%d"),
(local_end - timedelta(seconds=1)).astimezone(timezone.utc).strftime("%Y%m%d"),
}
return min(utc_dates), max(utc_dates), local_start.timestamp(), local_end.timestamp()
def filter_events_local_day(
events: list[dict],
local_start_ts: float,
local_end_ts: float,
ts_key: str = "event_timestamp",
) -> list[dict]:
"""Keep events whose kickoff falls in [local_start, local_end); keep missing timestamps."""
filtered: list[dict] = []
for event in events:
ts = event.get(ts_key)
if ts is None or (local_start_ts <= ts < local_end_ts):
filtered.append(event)
return filtered
def format_temperature_high_low(
config: Any,
high: Optional[Union[int, float]],