Godstrument/workers/world_sport.py
type-two 3ff105dd63 🏟 tune the stadium roster — seven sports round the clock
Soccer, basketball, cricket (the long game), Australian football (the
winter Saturday roar at home), fighting, motorsport, ice hockey. Seven
calls per refresh — still ~2% of the free key's budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:49:03 +10:00

129 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""Somewhere, a whistle blows — the world's sporting day via TheSportsDB.
OPT-IN (run.py --sports): the free tier allows 30 requests a minute on a
shared key and limited leagues, so this worker stays off until invited. It is
also polite by design: the day's schedule arrives in a few calls per hour and
everything else is computed at home, no live polling —
sport.events_today how many fixtures the planet plays today
sport.inplay matches being played right now (a stepped curve
climbing through the world's evenings)
sport.kickoff an impulse the moment any match begins, its magnitude
the number kicking off together — three o'clock on an
English Saturday rings like a carillon
Live scores are the paid tier; the schedule is free, and kickoffs are the
musical part anyway.
"""
import argparse
import datetime as dt
import json
import time
import urllib.parse
import urllib.request
from pythonosc.udp_client import SimpleUDPClient
NAME = "world_sport"
API = "https://www.thesportsdb.com/api/v1/json/{key}/eventsday.php?d={day}&s={sport}"
SPORTS = [ # a day-round, world-round spread; one call each per refresh
"Soccer", # the world's pulse, every timezone
"Basketball", # the American evening
"Cricket", # the long game — hours-long plateaus on the inplay curve
"Australian Football", # the winter Saturday roar at home
"Fighting", # weekend-night bursts
"Motorsport", # sunday engines
"Ice Hockey", # the northern winter, back each October
]
POLL_S = 900.0 # schedule refresh: 7 calls / 15 min — still ~2% of the limit
TICK_S = 10.0 # local clock: kickoff detection costs no API calls
INPLAY_S = 2 * 3600 # a match "in play" for ~2h from kickoff
USER_AGENT = "godstrument/1.0 (+world_sport)"
def fetch_day(key, sport, day):
url = API.format(key=urllib.parse.quote(key), day=day, sport=urllib.parse.quote(sport))
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode()).get("events") or []
def kickoff_times(events):
"""-> sorted UTC epoch seconds; events without a timestamp are skipped."""
out = []
for e in events:
ts = e.get("strTimestamp")
if not ts:
continue
try:
d = dt.datetime.fromisoformat(ts)
if d.tzinfo is None: # thesportsdb timestamps are UTC
d = d.replace(tzinfo=dt.timezone.utc)
out.append(d.timestamp())
except ValueError:
continue
return sorted(out)
def inplay(times, now):
return sum(1 for t in times if t <= now < t + INPLAY_S)
def kicked_between(times, t0, t1):
return sum(1 for t in times if t0 < t <= t1)
def refresh(key):
day = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d")
total, times = 0, []
for sport in SPORTS:
ev = fetch_day(key, sport, day)
total += len(ev)
times += kickoff_times(ev)
time.sleep(2.1) # 30/min shared key: stay courteous
return total, sorted(times)
def main():
parser = argparse.ArgumentParser(description="TheSportsDB fixtures -> OSC (opt-in)")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=9000)
parser.add_argument("--key", default="123", help="thesportsdb API key (free shared key: 123)")
parser.add_argument("--once", action="store_true", help="one fetch+emit, then exit (doubles as the self-check)")
args = parser.parse_args()
client = SimpleUDPClient(args.host, args.port)
print(f"[{NAME}] emitting /gs/sport/events_today,/gs/sport/inplay,/gs/sport/kickoff "
f"· schedule every {POLL_S:g}s, whistle watched every {TICK_S:g}s")
total, times, next_poll, last_tick = 0, [], 0.0, time.time()
while True:
now = time.time()
if now >= next_poll:
try:
total, times = refresh(args.key)
client.send_message("/gs/sport/events_today", float(total))
print(f"[{NAME}] {total} fixtures today, {inplay(times, now)} in play")
next_poll = now + POLL_S
except Exception as e: # transient network / parse error — keep going
print(f"[{NAME}] refresh error: {e}; retrying in {POLL_S / 10:g}s")
next_poll = now + POLL_S / 10
k = kicked_between(times, last_tick, now)
if k:
client.send_message("/gs/sport/kickoff", float(k))
print(f"[{NAME}] the whistle blows — {k} match{'es' if k > 1 else ''} kicking off")
client.send_message("/gs/sport/inplay", float(inplay(times, now)))
last_tick = now
if args.once:
assert total >= 0 and all(isinstance(t, float) for t in times), "nonsense schedule"
print(f"[{NAME}] once: ok — {total} fixtures, {len(times)} timestamped")
return
time.sleep(TICK_S)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass