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>
129 lines
5.2 KiB
Python
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
|