feat(worldcup): enhance goal tracking and scoring details in ESPNClient and WorldCupLiveService

- Updated ESPNClient to include a detailed list of scoring plays in the live match state, capturing clock, scorer, team ID, own goal, and penalty information.
- Modified WorldCupLiveService to track and announce goals, including formatting for new scorers and handling multiple goals since the last poll.
- Added unit tests to validate goal tracking functionality and ensure accurate announcements during matches.
This commit is contained in:
agessaman
2026-06-16 08:12:58 -07:00
parent 4bf60622ff
commit 69097cb258
3 changed files with 91 additions and 9 deletions
+21 -3
View File
@@ -157,9 +157,12 @@ class ESPNClient:
"""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}. Names are full team display names. Penalty
fields are None unless a shootout score is present. Used by the live-score service
to detect kickoff / goal / half-time / full-time transitions. Returns [] on error.
status, clock, home_pen, away_pen, goals}. 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} (penalty shootout kicks excluded). Used by the live-score service to
detect kickoff / goal / half-time / full-time transitions and name scorers.
Returns [] on error.
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.
@@ -182,6 +185,20 @@ class ESPNClient:
home = next((c for c in competitors if c.get('homeAway') == 'home'), competitors[0])
away = next((c for c in competitors if c.get('homeAway') == 'away'), competitors[1])
status_obj = competition.get('status', event.get('status', {}))
goals = []
for det in competition.get('details', []):
if not det.get('scoringPlay') or det.get('shootout'):
continue
athletes = det.get('athletesInvolved') or []
goals.append({
'clock': det.get('clock', {}).get('displayValue', ''),
'scorer': athletes[0].get('displayName', '') if athletes else '',
'team_id': str(det.get('team', {}).get('id', '')),
'own_goal': bool(det.get('ownGoal')),
'penalty': bool(det.get('penaltyKick')),
})
states.append({
'id': str(event.get('id', '')),
'home_id': str(home.get('team', {}).get('id', '')),
@@ -194,6 +211,7 @@ class ESPNClient:
'clock': status_obj.get('displayClock', ''),
'home_pen': self.extract_shootout_score(home),
'away_pen': self.extract_shootout_score(away),
'goals': goals,
})
return states
except Exception as e:
+31 -4
View File
@@ -189,7 +189,7 @@ class WorldCupLiveService(BaseServicePlugin):
changed = False
for m in matches:
eid = m["id"]
cur = {"h": m["home_score"], "a": m["away_score"], "s": m["status"]}
cur = {"h": m["home_score"], "a": m["away_score"], "s": m["status"], "g": len(m.get("goals") or [])}
prev = self._state.get(eid)
if prev is None:
# First sight: seed silently so in-progress matches aren't back-announced.
@@ -226,11 +226,38 @@ class WorldCupLiveService(BaseServicePlugin):
return self._score_line(label, m, cur, "full-time")
if self.announce_halftime and cs == HT_STATUS and ps != HT_STATUS:
return self._score_line(label, m, cur, "half-time")
if self.announce_goals and (cur["h"], cur["a"]) != (prev["h"], prev["a"]) and cs in PLAYING_STATUSES:
clock = (m.get("clock") or "").strip()
return self._score_line(label, m, cur, clock or None)
if (
self.announce_goals
and (cur["h"] + cur["a"]) > (prev["h"] + prev["a"]) # a goal was added (not a VAR removal)
and cs in PLAYING_STATUSES
):
return self._score_line(label, m, cur, self._goal_tag(prev, m))
return None
def _goal_tag(self, prev: dict, m: dict) -> Optional[str]:
"""Build the parenthetical tag for a goal: scorer(s) since last poll, else the clock."""
goals = m.get("goals") or []
# Only name scorers when we have a known prior count to diff against; otherwise the
# whole list would look "new" (e.g. on the first poll after an upgrade).
if "g" in prev and len(goals) >= prev["g"]:
new_goals = goals[prev["g"]:]
if new_goals:
return "; ".join(self._fmt_goal(g) for g in new_goals)
clock = (m.get("clock") or "").strip()
return clock or None
@staticmethod
def _fmt_goal(goal: dict) -> str:
"""Format one scoring play, e.g. \"64' Mohammad Mohebbi\" or \"7' Elijah Just, pen\"."""
clock = (goal.get("clock") or "").strip()
name = goal.get("scorer") or "?"
text = f"{clock} {name}".strip()
if goal.get("own_goal"):
text += ", OG"
elif goal.get("penalty"):
text += ", pen"
return text
def _score_line(self, label: str, m: dict, cur: dict, tag: Optional[str]) -> str:
line = f"{label}: {m['home_name']} {cur['h']}, {m['away_name']} {cur['a']}"
if tag == "full-time" and m.get("status") == "STATUS_FINAL_PEN" and m.get("home_pen") is not None:
+39 -2
View File
@@ -35,14 +35,18 @@ def _svc(**overrides):
def _match(eid="1", h=0, a=0, status="STATUS_SCHEDULED", clock="", hp=None, ap=None,
hid="100", aid="200", hn="Côte d'Ivoire", an="Ecuador"):
hid="100", aid="200", hn="Côte d'Ivoire", an="Ecuador", goals=None):
return {
"id": eid, "home_id": hid, "away_id": aid, "home_name": hn, "away_name": an,
"home_score": h, "away_score": a, "status": status, "clock": clock,
"home_pen": hp, "away_pen": ap,
"home_pen": hp, "away_pen": ap, "goals": goals or [],
}
def _goal(clock, scorer, own_goal=False, penalty=False, team_id="100"):
return {"clock": clock, "scorer": scorer, "team_id": team_id, "own_goal": own_goal, "penalty": penalty}
GROUPS = {"100": "Group E", "200": "Group E"}
@@ -63,6 +67,39 @@ class TestDetect:
m = _match(h=1, a=0, status="STATUS_FIRST_HALF", clock="23'")
assert self._detect(svc, prev, m) == "Group E: Côte d'Ivoire 1, Ecuador 0 (23')"
def test_goal_names_new_scorer(self):
svc = _svc()
prev = {"h": 0, "a": 0, "s": "STATUS_FIRST_HALF", "g": 0}
m = _match(h=1, a=0, status="STATUS_FIRST_HALF", clock="7'",
goals=[_goal("7'", "Elijah Just")])
assert self._detect(svc, prev, m) == "Group E: Côte d'Ivoire 1, Ecuador 0 (7' Elijah Just)"
def test_goal_penalty_annotation(self):
svc = _svc()
prev = {"h": 1, "a": 0, "s": "STATUS_SECOND_HALF", "g": 1}
m = _match(h=1, a=1, status="STATUS_SECOND_HALF", clock="64'",
goals=[_goal("7'", "Elijah Just"), _goal("64'", "Ramin Rezaeian", penalty=True, team_id="200")])
assert self._detect(svc, prev, m) == "Group E: Côte d'Ivoire 1, Ecuador 1 (64' Ramin Rezaeian, pen)"
def test_goal_multiple_since_last_poll(self):
svc = _svc()
prev = {"h": 0, "a": 0, "s": "STATUS_FIRST_HALF", "g": 0}
m = _match(h=2, a=0, status="STATUS_FIRST_HALF", clock="20'",
goals=[_goal("7'", "A. One"), _goal("20'", "B. Two")])
assert self._detect(svc, prev, m) == "Group E: Côte d'Ivoire 2, Ecuador 0 (7' A. One; 20' B. Two)"
def test_goal_falls_back_to_clock_without_details(self):
svc = _svc()
prev = {"h": 0, "a": 0, "s": "STATUS_FIRST_HALF", "g": 0}
m = _match(h=1, a=0, status="STATUS_FIRST_HALF", clock="12'") # no goals payload
assert self._detect(svc, prev, m) == "Group E: Côte d'Ivoire 1, Ecuador 0 (12')"
def test_var_removed_goal_not_announced(self):
svc = _svc()
prev = {"h": 1, "a": 0, "s": "STATUS_FIRST_HALF", "g": 1}
m = _match(h=0, a=0, status="STATUS_FIRST_HALF", clock="15'", goals=[])
assert self._detect(svc, prev, m) is None
def test_halftime(self):
svc = _svc()
prev = {"h": 0, "a": 0, "s": "STATUS_FIRST_HALF"}