
Sports Ticker
- 2 installs
- 3 repo stars
- Updated May 7, 2026
- robbyczgw-cla/sports-ticker
Tracks favorite sports teams and sends live score alerts using the free ESPN API, with a setup wizard, schedules, and auto-generated match-day crons.
About
An OpenClaw skill that polls the free ESPN API for live scores, goals, and final results across major sports leagues. A developer uses it to get alerts for chosen teams and to auto-schedule match-day monitoring crons.
- Tracks Soccer, NFL, NBA, NHL, MLB and F1 via the free ESPN API, no key needed
- Interactive setup wizard, live monitor, schedule view and auto-generated match-day crons
Sports Ticker by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,839 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/robbyczgw-cla/sports-ticker --skill sports-tickerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 3 |
| Last updated | May 7, 2026 |
| Repository | robbyczgw-cla/sports-ticker ↗ |
What it does
Tracks favorite sports teams and sends live score alerts using the free ESPN API, with a setup wizard, schedules, and auto-generated match-day crons.
Files
Sports Ticker
Track your favorite teams across multiple sports with FREE live alerts!
Supports: ⚽ Soccer • 🏈 NFL • 🏀 NBA • 🏒 NHL • ⚾ MLB • 🏎 F1
First Run (Onboarding)
When no config.json exists, running the setup script launches an interactive wizard:
python3 scripts/setup.pyThe wizard asks: 1. 📺 Which sports? — Pick from Soccer, NFL, NBA, NHL, MLB, F1 2. 🏆 Which teams? — Choose from popular teams or search for any team 3. 🔔 Alert style? — Live scores, final only, or daily digest 4. ⏰ Game-day reminders? — Get a heads-up 30 mins before kick-off 5. 🌙 Quiet hours? — Pause alerts while you sleep
After setup, your config.json is ready and you can start tracking!
Re-run setup anytime:
python3 scripts/setup.py --force # Overwrites existing configQuick Start
# First time? Just run setup!
python3 scripts/setup.py # Interactive wizard
# Find team IDs (any sport)
python3 scripts/setup.py find "Lakers" basketball
python3 scripts/setup.py find "Chiefs" football
python3 scripts/setup.py find "Barcelona" soccer
# Test
python3 scripts/ticker.pyConfig Example
{
"teams": [
{
"name": "Barcelona",
"emoji": "🔵🔴",
"sport": "soccer",
"espn_id": "83",
"espn_leagues": ["esp.1", "uefa.champions"]
},
{
"name": "Lakers",
"emoji": "🏀💜💛",
"sport": "basketball",
"espn_id": "13",
"espn_leagues": ["nba"]
}
]
}Commands
# Ticker for all teams
python3 scripts/ticker.py
# Live monitor (for cron)
python3 scripts/live_monitor.py
# League scoreboard
python3 scripts/ticker.py league nba basketball
python3 scripts/ticker.py league nfl football
python3 scripts/ticker.py league eng.1 soccer
# 📅 Schedule - View upcoming fixtures (NEW in v3!)
python3 scripts/schedule.py # All teams, next 14 days
python3 scripts/schedule.py --days 30 # Look further ahead
python3 scripts/schedule.py --team spurs # Specific team
python3 scripts/schedule.py --compact # One-liner format
python3 scripts/schedule.py --json # JSON output
# 🤖 Auto Setup Crons - Generate match-day crons (NEW in v3!)
python3 scripts/auto_setup_crons.py # All teams, next 7 days
python3 scripts/auto_setup_crons.py --team spurs --days 14
python3 scripts/auto_setup_crons.py --json # Machine-readable
python3 scripts/auto_setup_crons.py --commands # OpenClaw CLI commands
# ESPN direct
python3 scripts/espn.py leagues
python3 scripts/espn.py scoreboard nba basketball
python3 scripts/espn.py search "Chiefs" footballAlert Types
- 🏟 Game start (kick-off / tip-off)
- ⚽🏈🏀⚾ Scoring plays (goals, touchdowns, 3-pointers, home runs)
- 🟥 Red cards / Ejections
- ⏸ Halftime / Period breaks
- 🏁 Final results (WIN/LOSS/DRAW)
ESPN API (Free!)
No key needed. Covers all major sports and 50+ leagues worldwide.
Supported Sports:
- Soccer: Premier League, La Liga, Champions League, MLS, and 30+ more
- Football: NFL
- Basketball: NBA, WNBA, NCAA
- Hockey: NHL
- Baseball: MLB
- Racing: Formula 1
# Config with personal teams
config.json
# State file
.live_state.json
# Python
__pycache__/
*.pyc
*.pyo
# IDE
.vscode/
.idea/
# Environment files
.env
.env.*
!.env.example
.cache/
.data/
Changelog
[3.2.0] - 2026-03-27
Changed
- v3.2.0 — Phase 1 stability: removed hardcoded API key, added web search fallback for non-ESPN teams, added score caching
- ticker.py: Removed hardcoded Brave API key fallback — now uses env var only; if key absent, skips Brave and tries Serper directly
- live_monitor.py: Teams without
espn_idare no longer skipped — they are checked via web search (Brave/Serper) with change-detection against cache - cache.py (new): Score cache module — reads/writes
.score_cache.json; stores last_result, match_date, source, cached_at per team - ticker.py + live_monitor.py: Write to cache on result found; show cached "Last result: … (DD.MM.)" when no live data and no API key
[3.0.7] - 2026-03-03
Fixed
find_team_match()now filters by today's UTC date (today_only=Truedefault) — prevents matches from future weeks from being reported as "today"
[3.0.6] - 2026-03-03
Changed
- Added ESPN endpoint validation and synchronized security/docs updates.
[3.0.5] - 2026-02-11
Changed
- setup_crons.py: No longer uses
subprocess.run— outputs JSON configurations instead of executing commands directly - auto_setup_crons.py: Outputs JSON configs instead of CLI command strings
- Agent-Native Pattern: Scripts now output structured JSON that the agent processes via the OpenClaw cron tool. This is safer and more aligned with OpenClaw's architecture.
Removed
--commandsflag from auto_setup_crons.py (was outputting raw CLI commands)
Migration
If you were parsing CLI command output from these scripts, update to parse JSON instead. The JSON format includes full cron specifications ready for the OpenClaw cron API.
[3.0.3] - 2026-02-04
- Privacy cleanup: removed hardcoded paths and personal info from docs
{
"teams": [
{
"name": "Tottenham Hotspur",
"short_name": "Spurs",
"emoji": "🐓",
"sport": "soccer",
"espn_id": "367",
"espn_leagues": ["eng.1", "uefa.champions"],
"enabled": true
},
{
"name": "Bayern Munich",
"short_name": "Bayern",
"emoji": "🔴⚪",
"sport": "soccer",
"espn_id": "132",
"espn_leagues": ["ger.1", "uefa.champions"],
"enabled": true
},
{
"name": "Los Angeles Lakers",
"short_name": "Lakers",
"emoji": "🏀💜💛",
"sport": "basketball",
"espn_id": "13",
"espn_leagues": ["nba"],
"enabled": false
},
{
"name": "Kansas City Chiefs",
"short_name": "Chiefs",
"emoji": "🏈",
"sport": "football",
"espn_id": "12",
"espn_leagues": ["nfl"],
"enabled": false
},
{
"name": "New York Rangers",
"short_name": "Rangers",
"emoji": "🏒",
"sport": "hockey",
"espn_id": "4",
"espn_leagues": ["nhl"],
"enabled": false
}
],
"alerts": {
"goals": true,
"red_cards": true,
"halftime": true,
"fulltime": true,
"kickoff": true
}
}
MIT License
Copyright (c) 2026 Sports Ticker Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "@openclaw/sports-ticker",
"version": "3.2.0",
"description": "Live sports alerts with FREE ESPN API. Track soccer, football, basketball, hockey, baseball, F1 and more with real-time scoring, key events, and multi-team support. No API keys required!",
"keywords": [
"openclaw",
"skill",
"sports",
"football",
"soccer",
"basketball",
"hockey",
"baseball",
"f1",
"espn",
"live-scores",
"alerts",
"real-time"
],
"author": "robbyczgw-cla",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/robbyczgw-cla/sports-ticker.git"
},
"homepage": "https://clawhub.ai/robbyczgw-cla/sports-ticker",
"bugs": {
"url": "https://github.com/robbyczgw-cla/sports-ticker/issues"
},
"openclaw": {
"skill": true,
"triggers": ["sports", "score", "match", "game"],
"capabilities": [
"live-alerts",
"multi-sport",
"real-time"
],
"sports": ["soccer", "nfl", "nba", "nhl", "mlb", "f1"],
"requirements": {
"bins": ["python3"],
"env": {}
}
},
"files": [
"SKILL.md",
"README.md",
"scripts/",
"config/"
]
}
🏆 Sports Ticker
Live sports alerts with scoring updates and real-time stats — completely FREE!
Track soccer, football, basketball, hockey, baseball, F1, and more!
Built for OpenClaw but works standalone too.
✨ Features
- 🎯 Multi-sport support — Soccer, NFL, NBA, NHL, MLB, F1, and more!
- ⚽ Live scoring alerts with player names and times
- 🟥 Key events — cards, touchdowns, home runs, goals
- ⏸ Period updates — Halftime, quarters, intermissions
- 🏁 Final results with WIN/LOSS/DRAW
- 📊 Multi-team support — track as many teams as you want
- 📅 Schedule/Fixtures — view upcoming matches for next 30 days (NEW in v3!)
- 🤖 Auto-cron generation — never miss a match, set up alerts automatically (NEW in v3!)
- 🔄 Auto-scheduling — cron jobs for match days (OpenClaw)
- 💰 100% FREE — no API keys, no subscriptions!
🎯 The Secret Sauce: ESPN API
This skill uses ESPN's public API which provides:
- Real-time scores across multiple sports
- Scoring plays with player names and timestamps
- Game events (touchdowns, goals, home runs, etc.)
- Match/game statistics
No API key required! ESPN's API is open and free to use.
Supported Sports & Leagues
⚽ Soccer/Football
- Premier League (
eng.1), La Liga (esp.1), Bundesliga (ger.1), Serie A (ita.1) - Champions League (
uefa.champions), Europa League (uefa.europa) - MLS (
usa.1), Liga MX (mex.1), and 20+ more
🏈 American Football
- NFL (
nfl)
🏀 Basketball
- NBA (
nba), WNBA (wnba), NCAA (mens-college-basketball)
🏒 Hockey
- NHL (
nhl)
⚾ Baseball
- MLB (
mlb)
🏎 Racing
- Formula 1 (
f1)
🚀 Quick Start
1. Install
# Clone or copy to your skills directory
clawhub install sports-ticker
# Or manually
git clone https://github.com/your-repo/sports-ticker
cd sports-ticker2. Run the Setup Wizard 🧙♂
First time? The interactive wizard makes setup a breeze!
python3 scripts/setup.pyThe wizard walks you through: 1. 📺 Pick your sports — Soccer, NFL, NBA, NHL, MLB, F1 2. 🏆 Choose your teams — Popular picks or search any team 3. 🔔 Set alert style — Live scores / Final only / Daily digest 4. ⏰ Game-day reminders — Get notified 30 mins before kick-off 5. 🌙 Quiet hours — No alerts while you sleep
Example:
╔═══════════════════════════════════════════════════════╗
║ 🏆 SPORTS TICKER 🏆 ║
║ ⚽ 🏈 🏀 🏒 ⚾ 🏎 ║
║ Never miss a goal, touchdown, or checkered flag! ║
╚═══════════════════════════════════════════════════════╝3. Or Configure Manually
# Find team IDs directly
python3 scripts/setup.py find "Lakers" basketball
python3 scripts/setup.py find "Chiefs" football
python3 scripts/setup.py find "Barcelona" soccerCommon team IDs for reference:
Soccer:
- Tottenham: 367, Arsenal: 359, Liverpool: 364, Man City: 382, Man United: 360
- Barcelona: 83, Real Madrid: 86, Bayern: 132, PSG: 160, Juventus: 111
American Sports:
- Lakers: 13, Warriors: 9, Celtics: 2 (NBA)
- Chiefs: 12, 49ers: 25, Cowboys: 6 (NFL)
- Maple Leafs: 10, Oilers: 22, Rangers: 4 (NHL)
- Yankees: 10, Dodgers: 19, Red Sox: 2 (MLB)
3. Create config.json
cp config.example.json config.jsonEdit config.json:
{
"teams": [
{
"name": "Liverpool",
"short_name": "Liverpool",
"emoji": "🔴",
"sport": "soccer",
"espn_id": "364",
"espn_leagues": ["eng.1", "uefa.champions"],
"enabled": true
},
{
"name": "Los Angeles Lakers",
"short_name": "Lakers",
"emoji": "🏀💜💛",
"sport": "basketball",
"espn_id": "13",
"espn_leagues": ["nba"],
"enabled": true
},
{
"name": "Kansas City Chiefs",
"short_name": "Chiefs",
"emoji": "🏈",
"sport": "football",
"espn_id": "12",
"espn_leagues": ["nfl"],
"enabled": true
}
],
"alerts": {
"goals": true,
"red_cards": true,
"halftime": true,
"fulltime": true,
"kickoff": true
}
}4. Test It
# Show ticker for your teams
python3 scripts/ticker.py
# Check live matches
python3 scripts/live_monitor.py --verbose
# View a specific league
python3 scripts/ticker.py league eng.1 soccer
python3 scripts/ticker.py league nfl football
python3 scripts/ticker.py league nba basketball
# 📅 View upcoming fixtures (NEW in v3!)
python3 scripts/schedule.py # All teams, 14 days
python3 scripts/schedule.py --team spurs --days 30
python3 scripts/schedule.py --json # JSON output
# 🤖 Auto-generate match crons (NEW in v3!)
python3 scripts/auto_setup_crons.py --team spurs --days 14
python3 scripts/auto_setup_crons.py --json # JSON output for agent to process
# ESPN API commands
python3 scripts/espn.py leagues # List all sports/leagues
python3 scripts/espn.py leagues soccer # List soccer leagues
python3 scripts/espn.py scoreboard nba basketball
python3 scripts/espn.py search "Lakers" basketball📱 Example Alerts
⚽ Soccer Goal:
🎉 GOAL! 23'
⚽ Marcus Rashford (Manchester United)
Manchester United 1-0 Liverpool🏈 NFL Touchdown:
🎉 TOUCHDOWN! Q2 3:42
🏈 Patrick Mahomes (Kansas City Chiefs)
Chiefs 14-7 Bills🏀 NBA 3-Pointer:
🎉 3-POINTER! Q3 8:15
🎯 LeBron James (Los Angeles Lakers)
Lakers 78-72 Warriors🏒 NHL Goal:
🎉 GOAL! P2 12:34
🏒 Connor McDavid (Edmonton Oilers)
Oilers 3-2 Maple LeafsFinal Score:
🏁 FINAL - WIN! 🎉✅ 🏈
Kansas City Chiefs 31-24 Buffalo Bills🤖 OpenClaw Integration
Automatic Cron Setup
The easiest way to set up match-day alerts is with the setup script:
# Generate cron job configurations (outputs JSON for agent to use)
python3 scripts/setup_crons.py <telegram_id> <timezone>
# Example
python3 scripts/setup_crons.py 123456789 "Europe/London"
python3 scripts/setup_crons.py 123456789 "America/New_York"
# Just view the example cron configs
python3 scripts/setup_crons.py --listNote (v3.0.5): The script outputs JSON configurations that the agent uses with the OpenClaw cron tool. It does NOT create crons directly — the agent handles that via platform tools.
This generates 3 cron job configs:
| Cron Job | Schedule | Purpose |
|---|---|---|
football-match-check | Daily 9 AM | Checks if your teams play today |
spurs-live-ticker | Every 2 mins (disabled) | Live updates during matches |
spurs-reminder | Dynamic (disabled) | 30-min pre-match reminder |
How Auto-Scheduling Works
1. Morning check — football-match-check runs at 9 AM daily 2. Match found? — If any team plays today, it:
- Updates
spurs-live-tickerto start 5 mins before kickoff - Sets
spurs-reminderfor 30 mins before kickoff - Enables both crons
3. During match — spurs-live-ticker runs every 2 mins, sending goals/cards/events 4. No match? — Both crons stay disabled (no spam!)
Manual Cron Setup
If you prefer manual setup, here are the cron expressions:
# Daily match check at 9 AM
0 9 * * * # football-match-check
# Live ticker every 2 minutes (enable only during matches)
*/2 * * * * # spurs-live-ticker
# Pre-match reminder (set to 30 mins before kickoff)
30 14 * * * # spurs-reminder (example: 2:30 PM for 3 PM kickoff)Cron Payload Examples
Match Check (daily):
{
"message": "Check if any configured teams play today. If a match is found, update spurs-live-ticker to start 5 mins before kickoff and run for 3 hours. Enable spurs-reminder for 30 mins before kickoff."
}Live Ticker (during matches):
{
"message": "Run python3 scripts/live_monitor.py and send any new events (goals, cards, halftime, fulltime). Only message if there are updates."
}Live Monitor Script
During matches, run every 2 minutes:
python3 scripts/live_monitor.pyThe script only outputs when there are new events (goals, cards, etc.), making it perfect for cron-based alerting.
🔧 Scripts Reference
| Script | Purpose |
|---|---|
ticker.py | Show current status of your teams |
live_monitor.py | Check for live updates (for cron) |
schedule.py | 📅 View upcoming fixtures (v3) |
auto_setup_crons.py | 🤖 Generate match crons (v3) |
espn.py | Direct ESPN API access |
setup.py | Interactive setup wizard |
config.py | Configuration management |
📅 Schedule & Auto-Crons (v3 Feature)
The Problem (v2)
The old scoreboard-only approach meant:
- Only TODAY's matches were visible
- If your daily cron didn't run, you'd miss matches
- No advance notice of upcoming fixtures
The Solution (v3)
Schedule API scans future dates to find your team's fixtures:
# See what's coming up
python3 scripts/schedule.py --team spurs --days 30
# Output:
# 🐓 Tottenham Hotspur - Upcoming Fixtures
# 📅 Sat 07 Feb 12:30 UTC | @ Man United (Premier League)
# 📍 Old Trafford
# 📅 Tue 10 Feb 19:30 UTC | vs Newcastle (Premier League)
# 📍 Tottenham Hotspur StadiumAuto-Cron Generator creates match-day cron job configs:
python3 scripts/auto_setup_crons.py --team spurs --days 14
# Outputs JSON configs for:
# ✅ spurs-reminder-2026-02-07 (30 mins before kickoff)
# ✅ spurs-ticker-start-2026-02-07 (starts monitoring)
# ⏸ spurs-ticker-2026-02-07 (every 2 mins during match)Note (v3.0.5): The script outputs JSON configurations instead of CLI commands. The agent processes these JSON configs and creates crons via the OpenClaw cron tool.
Workflow
1. Run schedule.py weekly to see upcoming fixtures 2. Run auto_setup_crons.py to generate cron configs (JSON output) 3. Agent processes the JSON and creates crons via OpenClaw 4. Never miss a match again! 🎉
🌐 ESPN API Reference
Base URL: https://site.api.espn.com/apis/site/v2/sports
Endpoints
# Scoreboard (all today's games)
/{sport}/{league}/scoreboard
# Game details with events
/{sport}/{league}/summary?event={event_id}
# Team info
/{sport}/{league}/teams/{team_id}Sport & League Codes
Soccer: soccer/eng.1, soccer/esp.1, soccer/uefa.champions Football: football/nfl Basketball: basketball/nba, basketball/wnba Hockey: hockey/nhl Baseball: baseball/mlb Racing: racing/f1
📄 License
MIT — use it however you want!
📚 ESPN API Resources
- Public ESPN API Documentation — comprehensive endpoint list
- ESPN OpenAPI Spec — formal OpenAPI/Swagger schema
- Interactive API Explorer — try endpoints in browser
- Zuplo Guide — overview & alternatives
⚠ Note: ESPN's API is unofficial/undocumented. It's stable and widely used, but could change without notice. The skill handles errors gracefully.
🙏 Credits
- ESPN for their awesome (and free!) API
- pseudo-r, zuplo, and the community for API documentation
- Built with ❤ for football fans everywhere
---
Happy tracking! ⚽🏆
#!/usr/bin/env python3
"""
Auto Setup Crons - Generate cron configurations for upcoming fixtures.
Generates cron jobs for:
1. Pre-match reminder (30 mins before kickoff)
2. Live ticker activation (5 mins before kickoff)
3. Match monitoring (every 2 mins during match)
Usage:
auto_setup_crons.py # All teams, next 7 days
auto_setup_crons.py --days 14 # Next 14 days
auto_setup_crons.py --team spurs # Specific team
auto_setup_crons.py --team spurs --output crons.json
"""
import sys
import json
import argparse
from pathlib import Path
from datetime import datetime, timedelta, timezone
sys.path.insert(0, str(Path(__file__).parent))
from config import get_teams, get_team_by_name
# Import schedule functions
from schedule import get_team_schedule, get_all_fixtures_json
def fixture_to_crons(fixture: dict, reminder_mins: int = 30,
ticker_before_mins: int = 5, ticker_duration_mins: int = 180) -> list:
"""Generate cron configurations for a single fixture.
Args:
fixture: Fixture dict from schedule
reminder_mins: Minutes before kickoff for reminder
ticker_before_mins: Minutes before kickoff to start ticker
ticker_duration_mins: How long to run ticker (default 3 hours)
Returns:
List of cron config dicts ready for OpenClaw cron API
"""
crons = []
date_str = fixture.get("date", "")
if not date_str:
return []
try:
kickoff = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except:
return []
# Skip if already in the past
now = datetime.now(timezone.utc)
if kickoff <= now:
return []
team_name = fixture.get("team_name", "Team")
team_short = fixture.get("team_short", team_name.split()[0])
opponent = fixture.get("opponent_short", fixture.get("opponent", "Opponent"))
comp = fixture.get("competition", "Match")
is_home = fixture.get("is_home", True)
event_id = fixture.get("event_id", "")
league = fixture.get("league", "eng.1")
sport = fixture.get("sport", "soccer")
# Match description
home_away = "vs" if is_home else "@"
match_desc = f"{team_short} {home_away} {opponent} ({comp})"
# Date parts for cron expression
kickoff_date = kickoff.strftime("%Y-%m-%d")
# 1. Pre-match reminder
reminder_time = kickoff - timedelta(minutes=reminder_mins)
if reminder_time > now:
reminder_cron = f"{reminder_time.minute} {reminder_time.hour} {reminder_time.day} {reminder_time.month} *"
crons.append({
"name": f"{team_short.lower()}-reminder-{kickoff_date}",
"description": f"Pre-match reminder: {match_desc}",
"schedule": reminder_cron,
"oneshot": True,
"enabled": True,
"message": f"⏰ **Match Reminder!**\n\n{fixture.get('team_emoji', '⚽')} {match_desc}\n\n🕐 Kickoff in {reminder_mins} minutes!\n📍 {fixture.get('venue', 'TBD')}",
"metadata": {
"type": "reminder",
"fixture_id": event_id,
"kickoff": date_str,
"team": team_short
}
})
# 2. Live ticker start (one-shot to enable the monitoring)
ticker_start = kickoff - timedelta(minutes=ticker_before_mins)
if ticker_start > now:
ticker_start_cron = f"{ticker_start.minute} {ticker_start.hour} {ticker_start.day} {ticker_start.month} *"
# End time for ticker (kickoff + duration)
ticker_end = kickoff + timedelta(minutes=ticker_duration_mins)
crons.append({
"name": f"{team_short.lower()}-ticker-start-{kickoff_date}",
"description": f"Start live ticker: {match_desc}",
"schedule": ticker_start_cron,
"oneshot": True,
"enabled": True,
"message": f"Start live monitoring for {match_desc}. Run `python3 scripts/live_monitor.py` every 2 minutes until match ends. Event ID: {event_id}, League: {league}, Sport: {sport}",
"metadata": {
"type": "ticker_start",
"fixture_id": event_id,
"league": league,
"sport": sport,
"kickoff": date_str,
"end_time": ticker_end.isoformat(),
"team": team_short
}
})
# 3. Live ticker (repeating during match)
# This runs every 2 minutes for the duration of the match
# Uses a different approach: window-based cron
crons.append({
"name": f"{team_short.lower()}-ticker-{kickoff_date}",
"description": f"Live updates: {match_desc}",
"schedule": "*/2 * * * *", # Every 2 minutes
"oneshot": False,
"enabled": False, # Disabled by default, enabled by ticker_start
"window": {
"start": (kickoff - timedelta(minutes=ticker_before_mins)).isoformat(),
"end": (kickoff + timedelta(minutes=ticker_duration_mins)).isoformat()
},
"message": f"Check for updates on {match_desc}. Run `python3 scripts/live_monitor.py --event {event_id} --league {league} --sport {sport}`. Only report new events.",
"metadata": {
"type": "ticker",
"fixture_id": event_id,
"league": league,
"sport": sport,
"kickoff": date_str,
"team": team_short
}
})
return crons
def generate_cron_configs(fixtures: list, reminder_mins: int = 30) -> list:
"""Generate cron configs for a list of fixtures."""
all_crons = []
for fixture in fixtures:
crons = fixture_to_crons(fixture, reminder_mins=reminder_mins)
all_crons.extend(crons)
return all_crons
def format_cron_summary(crons: list) -> str:
"""Format a human-readable summary of cron configs."""
if not crons:
return "No cron jobs to generate (no upcoming fixtures found)."
lines = ["📅 **Generated Cron Jobs**\n"]
# Group by fixture
fixtures = {}
for cron in crons:
fixture_id = cron.get("metadata", {}).get("fixture_id", "unknown")
if fixture_id not in fixtures:
fixtures[fixture_id] = {
"desc": cron.get("description", ""),
"crons": []
}
fixtures[fixture_id]["crons"].append(cron)
for fixture_id, data in fixtures.items():
lines.append(f"\n🎯 **{data['desc'].split(': ')[-1]}**")
for cron in data["crons"]:
cron_type = cron.get("metadata", {}).get("type", "?")
enabled = "✅" if cron.get("enabled") else "⏸️"
schedule = cron.get("schedule", "?")
lines.append(f" {enabled} `{cron['name']}` ({cron_type})")
lines.append(f" Schedule: `{schedule}`")
lines.append(f"\n**Total: {len(crons)} cron jobs for {len(fixtures)} fixtures**")
return "\n".join(lines)
def output_openclaw_commands(crons: list) -> str:
"""Generate cron job JSON configs for the agent to use with the cron tool.
NOTE: Does NOT execute any commands. Outputs JSON that the agent
should pass to the platform cron tool (cron action=add).
"""
if not crons:
return json.dumps({"message": "No crons to add"})
return json.dumps({
"instructions": "Use the cron tool (action=add) to create these jobs. Do NOT use subprocess or CLI.",
"crons": crons
}, indent=2)
def main():
parser = argparse.ArgumentParser(
description="Generate cron configurations for upcoming fixtures",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # All teams, next 7 days
%(prog)s --days 14 # Next 14 days
%(prog)s --team spurs # Only Spurs
%(prog)s --output crons.json # Save to file
%(prog)s --commands # Output as CLI commands
"""
)
parser.add_argument("--days", "-d", type=int, default=7,
help="Days to look ahead (default: 7)")
parser.add_argument("--team", "-t", type=str,
help="Filter by team name")
parser.add_argument("--reminder", "-r", type=int, default=30,
help="Reminder minutes before kickoff (default: 30)")
parser.add_argument("--output", "-o", type=str,
help="Output JSON file path")
parser.add_argument("--commands", "-c", action="store_true",
help="Output as OpenClaw CLI commands")
parser.add_argument("--json", "-j", action="store_true",
help="Output as JSON")
parser.add_argument("--summary", "-s", action="store_true",
help="Show human-readable summary (default)")
args = parser.parse_args()
# Get fixtures
fixtures = get_all_fixtures_json(days=args.days, team_name=args.team)
if not fixtures:
print("No upcoming fixtures found.")
sys.exit(0)
# Generate cron configs
crons = generate_cron_configs(fixtures, reminder_mins=args.reminder)
# Output
if args.output:
Path(args.output).write_text(json.dumps(crons, indent=2))
print(f"✅ Wrote {len(crons)} cron configs to {args.output}")
elif args.commands:
print(output_openclaw_commands(crons))
elif args.json:
print(json.dumps(crons, indent=2))
else:
# Default: summary
print(format_cron_summary(crons))
print("\n💡 Use --json or --commands for machine-readable output")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Score cache — persists last known results for all tracked teams.
Used by ticker.py and live_monitor.py.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
CACHE_FILE = Path(__file__).parent.parent / ".score_cache.json"
def load_cache() -> dict:
"""Load full cache from disk."""
if CACHE_FILE.exists():
try:
return json.loads(CACHE_FILE.read_text())
except Exception:
return {}
return {}
def save_cache(cache: dict):
"""Write cache to disk."""
CACHE_FILE.write_text(json.dumps(cache, indent=2))
def write_to_cache(team_key: str, result: str, source: str = "web_search"):
"""Store a result in the cache (creates or updates entry)."""
cache = load_cache()
now = datetime.now(timezone.utc)
cache[team_key] = {
"last_result": result,
"match_date": now.strftime("%Y-%m-%d"),
"source": source,
"cached_at": now.isoformat(),
}
save_cache(cache)
def read_from_cache(team_key: str) -> dict | None:
"""Get cached entry for a team, or None if not cached."""
return load_cache().get(team_key)
def format_cached_result(team_key: str) -> str:
"""Return a human-readable 'Last result: …' string, or empty string if no cache."""
entry = read_from_cache(team_key)
if not entry:
return ""
result = entry.get("last_result", "")
date_str = entry.get("match_date", "")
if date_str:
try:
date_label = datetime.strptime(date_str, "%Y-%m-%d").strftime("%d.%m.")
except ValueError:
date_label = date_str
return f"Last result: {result} ({date_label})"
return f"Last result: {result}"
#!/usr/bin/env python3
"""Configuration handler for sports-ticker."""
import json
from pathlib import Path
from typing import Optional
SKILL_DIR = Path(__file__).parent.parent
CONFIG_FILE = SKILL_DIR / "config.json"
CONFIG_EXAMPLE = SKILL_DIR / "config.example.json"
def load_config() -> dict:
"""Load configuration from config.json."""
try:
if CONFIG_FILE.exists():
return json.loads(CONFIG_FILE.read_text())
elif CONFIG_EXAMPLE.exists():
print(f"⚠️ No config.json found. Copy config.example.json to config.json and customize it.")
return json.loads(CONFIG_EXAMPLE.read_text())
else:
return {"teams": [], "alerts": {}}
except json.JSONDecodeError as e:
print(f"⚠️ Invalid JSON in config file: {e}")
return {"teams": [], "alerts": {}}
def save_config(config: dict):
"""Save configuration to config.json."""
CONFIG_FILE.write_text(json.dumps(config, indent=2))
def get_teams() -> list:
"""Get list of enabled teams."""
config = load_config()
return [t for t in config.get("teams", []) if t.get("enabled", True)]
def get_team_by_name(name: str) -> Optional[dict]:
"""Find a team by name (case-insensitive)."""
name_lower = name.lower()
for team in get_teams():
if name_lower in team.get("name", "").lower() or name_lower in team.get("short_name", "").lower():
return team
return None
def get_alert_settings() -> dict:
"""Get alert configuration."""
config = load_config()
return config.get("alerts", {
"goals": True,
"red_cards": True,
"halftime": True,
"fulltime": True,
"kickoff": True
})
def add_team(name: str, espn_id: str = None, thesportsdb_id: str = None,
leagues: list = None, emoji: str = "⚽", short_name: str = None, sport: str = "soccer"):
"""Add a team to config."""
config = load_config()
team = {
"name": name,
"short_name": short_name or name.split()[0],
"emoji": emoji,
"sport": sport,
"enabled": True
}
if espn_id:
team["espn_id"] = espn_id
# Default leagues based on sport
if leagues:
team["espn_leagues"] = leagues
elif sport == "soccer":
team["espn_leagues"] = ["eng.1", "uefa.champions"]
elif sport == "football":
team["espn_leagues"] = ["nfl"]
elif sport == "basketball":
team["espn_leagues"] = ["nba"]
elif sport == "hockey":
team["espn_leagues"] = ["nhl"]
elif sport == "baseball":
team["espn_leagues"] = ["mlb"]
elif sport == "racing":
team["espn_leagues"] = ["f1"]
else:
team["espn_leagues"] = []
if thesportsdb_id:
team["thesportsdb_id"] = thesportsdb_id
config.setdefault("teams", []).append(team)
save_config(config)
print(f"✅ Added {name} to config.json")
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Config Manager")
print("\nUsage:")
print(" config.py show - Show current config")
print(" config.py teams - List configured teams")
sys.exit(0)
cmd = sys.argv[1]
if cmd == "show":
print(json.dumps(load_config(), indent=2))
elif cmd == "teams":
teams = get_teams()
print(f"Configured teams ({len(teams)}):\n")
for t in teams:
emoji = t.get("emoji", "⚽")
name = t.get("name")
espn = t.get("espn_id", "-")
tsdb = t.get("thesportsdb_id", "-")
print(f" {emoji} {name:25} ESPN:{espn:6} TSDB:{tsdb}")
#!/usr/bin/env python3
"""
ESPN Sports API - FREE live scores with goal scorers, cards, and more!
No API key required. Works for:
- Football/Soccer (Premier League, Champions League, La Liga, Serie A, etc.)
- NFL, NBA, MLB, NHL (American sports)
- And many more...
Base URL: https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}
"""
import urllib.request
import json
import sys
from typing import Optional
from pathlib import Path
# ESPN API Base
BASE_URL = "https://site.api.espn.com/apis/site/v2/sports"
# Sport-to-URL mapping
SPORT_MAPPING = {
"soccer": "soccer",
"football": "football", # American football
"basketball": "basketball",
"hockey": "hockey",
"baseball": "baseball",
"racing": "racing",
}
# League display names by sport
LEAGUES = {
"soccer": {
# European
"eng.1": "Premier League",
"eng.2": "Championship",
"esp.1": "La Liga",
"ger.1": "Bundesliga",
"ita.1": "Serie A",
"fra.1": "Ligue 1",
"ned.1": "Eredivisie",
"por.1": "Primeira Liga",
"ger.1": "German Bundesliga",
# European competitions
"uefa.champions": "Champions League",
"uefa.europa": "Europa League",
"uefa.europa.conf": "Conference League",
# Americas
"usa.1": "MLS",
"mex.1": "Liga MX",
"bra.1": "Brasileirão",
"arg.1": "Argentine Primera",
# International
"fifa.world": "World Cup",
"uefa.euro": "Euros",
},
"football": {
"nfl": "NFL",
},
"basketball": {
"nba": "NBA",
"wnba": "WNBA",
"mens-college-basketball": "NCAA Basketball (M)",
"womens-college-basketball": "NCAA Basketball (W)",
},
"hockey": {
"nhl": "NHL",
},
"baseball": {
"mlb": "MLB",
},
"racing": {
"f1": "Formula 1",
},
}
# Backward compatibility
FOOTBALL_LEAGUES = LEAGUES["soccer"]
def api_request(endpoint: str) -> dict:
"""Make request to ESPN API (no auth needed!)."""
import re
# Validate endpoint contains only safe characters (alphanumeric, slashes, dots, hyphens, query params)
if not re.match(r'^[a-zA-Z0-9/_.\-?&=]+$', endpoint):
raise ValueError(f"Invalid endpoint: {endpoint}")
url = f"{BASE_URL}/{endpoint}"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
return json.loads(urllib.request.urlopen(req, timeout=15).read())
def get_scoreboard(sport: str = "soccer", league: str = "eng.1") -> dict:
"""Get current scoreboard for a league."""
return api_request(f"{sport}/{league}/scoreboard")
def get_match_details(event_id: str, sport: str = "soccer", league: str = "eng.1") -> dict:
"""Get detailed match info including events."""
return api_request(f"{sport}/{league}/summary?event={event_id}")
def get_all_teams(league: str = "eng.1", sport: str = "soccer") -> list:
"""Get ALL teams in a league (not just today's matches)."""
sport_url = SPORT_MAPPING.get(sport, sport)
data = api_request(f"{sport_url}/{league}/teams")
teams = data.get("sports", [{}])[0].get("leagues", [{}])[0].get("teams", [])
return [t.get("team", {}) for t in teams]
def search_team(team_name: str, sport: str = "soccer", leagues: list = None) -> list:
"""Search for a team across leagues using the teams endpoint."""
if leagues is None:
# Default leagues based on sport
if sport == "soccer":
leagues = ["eng.1", "esp.1", "ger.1", "ita.1", "fra.1", "uefa.champions"]
elif sport == "football":
leagues = ["nfl"]
elif sport == "basketball":
leagues = ["nba"]
elif sport == "hockey":
leagues = ["nhl"]
elif sport == "baseball":
leagues = ["mlb"]
elif sport == "racing":
leagues = ["f1"]
else:
leagues = []
team_lower = team_name.lower()
results = []
sport_leagues = LEAGUES.get(sport, {})
for league in leagues:
try:
teams = get_all_teams(league, sport)
for team in teams:
if team_lower in team.get("displayName", "").lower() or \
team_lower in team.get("shortDisplayName", "").lower() or \
team_lower in team.get("nickname", "").lower():
results.append({
"id": team.get("id"),
"name": team.get("displayName"),
"short": team.get("shortDisplayName"),
"sport": sport,
"league": league,
"league_name": sport_leagues.get(league, league)
})
except Exception:
continue
return results
def find_team_match(team_id: str, leagues: list = None, sport: str = "soccer", today_only: bool = True) -> Optional[dict]:
"""Find a match for a specific team.
If today_only=True (default), only returns matches scheduled for today (UTC).
"""
from datetime import datetime, timezone
if leagues is None:
leagues = ["eng.1", "uefa.champions"]
today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
for league in leagues:
try:
data = get_scoreboard(sport, league)
for event in data.get("events", []):
# Check date if today_only
if today_only:
event_date = event.get("date", "")[:10] # "2026-03-10T..."[:10] = "2026-03-10"
if event_date != today_str:
continue
for comp in event.get("competitions", []):
for competitor in comp.get("competitors", []):
if str(competitor.get("team", {}).get("id")) == str(team_id):
return {
"event": event,
"league": league,
"event_id": event.get("id"),
"sport": sport
}
except Exception:
continue
return None
def get_match_events(event_id: str, sport: str = "soccer", league: str = "eng.1") -> list:
"""Get key events (goals, cards) from a match."""
details = get_match_details(event_id, sport, league)
return details.get("keyEvents", [])
def format_event(event: dict, sport: str = "soccer") -> str:
"""Format a match/game event for display."""
event_type = event.get("type", {}).get("text", "")
clock = event.get("clock", {}).get("displayValue", "?'")
team = event.get("team", {}).get("displayName", "")
participants = event.get("participants", [])
player = participants[0].get("athlete", {}).get("displayName", "") if participants else ""
# Soccer events
if sport == "soccer":
if "Goal" in event_type:
detail = ""
if "Own Goal" in event_type:
detail = " (OG)"
elif "Penalty" in event_type:
detail = " (pen)"
return f"⚽ {clock} {player}{detail} ({team})"
elif "Yellow" in event_type:
return f"🟨 {clock} {player} ({team})"
elif "Red" in event_type:
return f"🟥 {clock} {player} ({team})"
elif "Substitution" in event_type:
return f"🔄 {clock} {player} ({team})"
# American Football events
elif sport == "football":
if "Touchdown" in event_type:
return f"🏈 {clock} TOUCHDOWN - {player} ({team})"
elif "Field Goal" in event_type:
return f"🎯 {clock} Field Goal - {player} ({team})"
elif "Interception" in event_type:
return f"🔒 {clock} INT - {player} ({team})"
# Basketball events
elif sport == "basketball":
if "Three Point" in event_type or "3PT" in event_type:
return f"🎯 {clock} 3-pointer - {player} ({team})"
elif "Dunk" in event_type:
return f"💪 {clock} DUNK - {player} ({team})"
# Hockey events
elif sport == "hockey":
if "Goal" in event_type:
return f"🏒 {clock} GOAL - {player} ({team})"
elif "Penalty" in event_type:
return f"⏱️ {clock} Penalty - {player} ({team})"
# Baseball events
elif sport == "baseball":
if "Home Run" in event_type or "HR" in event_type:
return f"⚾ {clock} HOME RUN - {player} ({team})"
return f"📋 {clock} {event_type}: {player}"
def format_match(event: dict, include_events: bool = True, sport: str = "soccer", league: str = "eng.1") -> str:
"""Format a full match/game summary."""
lines = []
status = event.get("status", {})
status_desc = status.get("type", {}).get("description", "Unknown")
clock = status.get("displayClock", "")
# Status header (sport-specific terminology)
if status_desc == "In Progress":
lines.append(f"🔴 LIVE {clock}")
elif status_desc == "Halftime":
if sport in ["football", "basketball"]:
lines.append("⏸️ HALFTIME")
else:
lines.append("⏸️ HALFTIME")
elif "End of" in status_desc: # Basketball/Hockey periods
lines.append(f"⏸️ {status_desc}")
elif status_desc == "Final":
if sport == "soccer":
lines.append("🏁 FULL TIME")
else:
lines.append("🏁 FINAL")
else:
lines.append(f"📅 {status_desc}")
# Teams and score
competitions = event.get("competitions", [{}])[0]
competitors = competitions.get("competitors", [])
home = away = ""
home_score = away_score = "0"
for c in competitors:
team_name = c.get("team", {}).get("displayName", "?")
score = c.get("score", "0")
if c.get("homeAway") == "home":
home, home_score = team_name, score
else:
away, away_score = team_name, score
lines.append(f"**{home} {home_score} - {away_score} {away}**")
# Events
if include_events:
event_id = event.get("id")
if event_id:
try:
events = get_match_events(event_id, sport, league)
if events:
lines.append("")
for e in events[-8:]:
lines.append(format_event(e, sport))
except Exception:
pass
return "\n".join(lines)
def get_fixtures_for_date(date_str: str, sport: str = "soccer", league: str = "eng.1") -> list:
"""Get fixtures for a specific date (YYYYMMDD format).
Returns list of events for that date.
"""
try:
data = api_request(f"{sport}/{league}/scoreboard?dates={date_str}")
return data.get("events", [])
except Exception:
return []
def get_team_fixtures(team_id: str, days: int = 30, sport: str = "soccer",
leagues: list = None) -> list:
"""Get upcoming fixtures for a team across leagues.
Args:
team_id: ESPN team ID
days: Number of days to look ahead (default 30)
sport: Sport type
leagues: List of leagues to check (defaults based on sport)
Returns:
List of fixture dicts with date, opponent, competition, venue, event_id
"""
from datetime import datetime, timedelta, timezone
if leagues is None:
if sport == "soccer":
leagues = ["eng.1", "esp.1", "ger.1", "ita.1", "fra.1",
"uefa.champions", "uefa.europa"]
elif sport == "football":
leagues = ["nfl"]
elif sport == "basketball":
leagues = ["nba"]
elif sport == "hockey":
leagues = ["nhl"]
elif sport == "baseball":
leagues = ["mlb"]
elif sport == "racing":
leagues = ["f1"]
else:
leagues = []
fixtures = []
team_id_str = str(team_id)
now = datetime.now(timezone.utc)
# Check each day for the next N days
checked_dates = set()
for day_offset in range(days + 1): # Include today
date = now + timedelta(days=day_offset)
date_str = date.strftime("%Y%m%d")
for league in leagues:
cache_key = f"{date_str}_{league}"
if cache_key in checked_dates:
continue
checked_dates.add(cache_key)
events = get_fixtures_for_date(date_str, sport, league)
for event in events:
# Check if this team is in the match
for comp in event.get("competitions", []):
competitors = comp.get("competitors", [])
team_in_match = False
home_team = away_team = None
for c in competitors:
team = c.get("team", {})
if c.get("homeAway") == "home":
home_team = team
else:
away_team = team
if str(team.get("id")) == team_id_str:
team_in_match = True
if team_in_match and home_team and away_team:
# Determine opponent
if str(home_team.get("id")) == team_id_str:
opponent = away_team
is_home = True
else:
opponent = home_team
is_home = False
sport_leagues = LEAGUES.get(sport, {})
fixtures.append({
"event_id": event.get("id"),
"date": event.get("date"),
"date_str": event.get("date", "")[:10],
"opponent": opponent.get("displayName"),
"opponent_short": opponent.get("shortDisplayName", opponent.get("displayName")),
"opponent_id": opponent.get("id"),
"is_home": is_home,
"competition": sport_leagues.get(league, league),
"league": league,
"venue": comp.get("venue", {}).get("fullName", "TBD"),
"sport": sport,
"status": event.get("status", {}).get("type", {}).get("name", "scheduled")
})
# Sort by date and remove duplicates (same event in multiple leagues)
seen_ids = set()
unique_fixtures = []
for f in sorted(fixtures, key=lambda x: x["date"]):
if f["event_id"] not in seen_ids:
seen_ids.add(f["event_id"])
unique_fixtures.append(f)
return unique_fixtures
def format_fixture(fixture: dict, include_venue: bool = True) -> str:
"""Format a fixture for display."""
from datetime import datetime, timezone
date_str = fixture.get("date", "")
opponent = fixture.get("opponent_short", fixture.get("opponent", "?"))
comp = fixture.get("competition", "")
is_home = fixture.get("is_home", True)
venue = fixture.get("venue", "")
# Parse date
try:
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
date_fmt = dt.strftime("%a %d %b")
time_fmt = dt.strftime("%H:%M")
except:
date_fmt = date_str[:10]
time_fmt = "TBD"
# Home/Away indicator
location = "vs" if is_home else "@"
result = f"📅 {date_fmt} {time_fmt} UTC | {location} {opponent} ({comp})"
if include_venue and venue:
result += f"\n 📍 {venue}"
return result
def list_leagues(sport: str = None):
"""List available leagues by sport."""
if sport:
if sport in LEAGUES:
print(f"Available {sport.title()} Leagues:\n")
for code, name in sorted(LEAGUES[sport].items(), key=lambda x: x[1]):
print(f" {code:20} {name}")
else:
print(f"Sport '{sport}' not found. Available: {', '.join(LEAGUES.keys())}")
else:
print("Available Leagues by Sport:\n")
for sport_name, leagues in LEAGUES.items():
print(f"\n{sport_name.upper()}:")
for code, name in sorted(leagues.items(), key=lambda x: x[1]):
print(f" {code:20} {name}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("ESPN Sports API - Free live data!")
print("\nUsage:")
print(" espn.py leagues [sport] - List available leagues")
print(" espn.py scoreboard [league] [sport] - Get scoreboard (default: eng.1 soccer)")
print(" espn.py search <team> [sport] - Search for a team (default: soccer)")
print(" espn.py match <event_id> [league] [sport] - Get match details")
print("\nSupported sports: soccer, football, basketball, hockey, baseball, racing")
sys.exit(0)
cmd = sys.argv[1]
if cmd == "leagues":
sport = sys.argv[2] if len(sys.argv) > 2 else None
list_leagues(sport)
elif cmd == "scoreboard":
league = sys.argv[2] if len(sys.argv) > 2 else "eng.1"
sport = sys.argv[3] if len(sys.argv) > 3 else "soccer"
data = get_scoreboard(sport, league)
sport_leagues = LEAGUES.get(sport, {})
print(f"=== {sport_leagues.get(league, league)} ===\n")
for event in data.get("events", []):
print(format_match(event, include_events=False, sport=sport, league=league))
print()
elif cmd == "search":
if len(sys.argv) < 3:
print("Usage: espn.py search <team_name> [sport]")
sys.exit(1)
# Find where sport might be (last arg if it's a known sport)
args = sys.argv[2:]
sport = "soccer"
if args and args[-1] in SPORT_MAPPING:
sport = args[-1]
team = " ".join(args[:-1])
else:
team = " ".join(args)
results = search_team(team, sport)
if results:
print(f"Found teams matching '{team}' in {sport}:\n")
for r in results:
print(f" ID: {r['id']:6} | {r['name']:30} | {r['league_name']}")
else:
print(f"No teams found matching '{team}' in {sport}")
elif cmd == "match":
if len(sys.argv) < 3:
print("Usage: espn.py match <event_id> [league] [sport]")
sys.exit(1)
event_id = sys.argv[2]
league = sys.argv[3] if len(sys.argv) > 3 else "eng.1"
sport = sys.argv[4] if len(sys.argv) > 4 else "soccer"
details = get_match_details(event_id, sport, league)
event = details.get("header", {}).get("competitions", [{}])[0]
# Reconstruct event format
print(f"Match details for event {event_id}")
#!/usr/bin/env python3
"""
Live Match Monitor - Checks for live matches and outputs alerts.
Uses ESPN API for real-time events including:
- ⚽ Goals with scorer names
- 🟥 Red cards
- ⏸️ Halftime
- 🏁 Full-time results
Run via cron during match windows. Only outputs when there are new events.
"""
import json
import sys
import os
import urllib.request
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from espn import find_team_match, get_match_events, FOOTBALL_LEAGUES
from config import get_teams, get_alert_settings
from cache import write_to_cache, read_from_cache, format_cached_result
STATE_FILE = Path(__file__).parent.parent / ".live_state.json"
def _web_search_live(team_name: str, team_key: str) -> list:
"""Search for live score info for a team without ESPN ID.
Returns a list of alert strings (may be empty if nothing found or no API key).
Writes result to score cache when something is found.
"""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
queries = [
f"{team_name} live score today",
f"{team_name} Ergebnis heute live",
]
def _try_brave(query: str) -> str:
brave_key = os.environ.get("BRAVE_SEARCH_API_KEY", "")
if not brave_key:
return ""
try:
params = urllib.parse.urlencode({"q": query, "count": 3})
req = urllib.request.Request(
f"https://api.search.brave.com/res/v1/web/search?{params}",
headers={"Accept": "application/json", "X-Subscription-Token": brave_key},
)
data = json.loads(urllib.request.urlopen(req, timeout=10).read())
snippets = []
for r in data.get("web", {}).get("results", [])[:3]:
snippet = r.get("description", "")
title = r.get("title", "")
if snippet:
snippets.append(f"{title}: {snippet[:120]}")
return "\n".join(snippets)
except Exception:
return ""
def _try_serper(query: str) -> str:
# Load key from env or web-search-plus .env
serper_key = os.environ.get("SERPER_API_KEY", "")
if not serper_key:
env_paths = [
Path(__file__).parent.parent.parent / "web-search-plus" / ".env",
Path(os.environ.get("HOME", "/root")) / "clawd/skills/web-search-plus/.env",
]
for ep in env_paths:
if ep.exists():
for line in ep.read_text().splitlines():
if "SERPER_API_KEY" in line and "=" in line:
serper_key = line.split("=", 1)[-1].strip().strip("'\"")
break
if not serper_key:
return ""
try:
import json as _json
payload = _json.dumps({"q": query, "num": 3}).encode()
req = urllib.request.Request(
"https://google.serper.dev/search",
data=payload,
headers={"X-API-KEY": serper_key, "Content-Type": "application/json"},
)
data = _json.loads(urllib.request.urlopen(req, timeout=10).read())
snippets = []
for r in data.get("organic", [])[:3]:
snippet = r.get("snippet", "")
title = r.get("title", "")
if snippet:
snippets.append(f"{title}: {snippet[:120]}")
return "\n".join(snippets)
except Exception:
return ""
alerts = []
for query in queries:
result = _try_brave(query) or _try_serper(query)
if result:
# Check if result changed from cache
cached = read_from_cache(team_key)
cached_result = cached.get("last_result", "") if cached else ""
if result != cached_result:
write_to_cache(team_key, result, source="web_search")
alerts.append(f"🔍 **{team_name}** (web)\n{result}")
break # Found something — stop trying queries
return alerts
def load_state() -> dict:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text())
return {}
def save_state(state: dict):
STATE_FILE.write_text(json.dumps(state, indent=2))
def check_team(team: dict, state: dict, alerts_config: dict) -> tuple[list, dict]:
"""Check a single team for updates. Returns (alerts, updated_state)."""
alerts = []
team_name = team.get("name", "Unknown")
team_emoji = team.get("emoji", "⚽")
espn_id = team.get("espn_id")
sport = team.get("sport", "soccer") # Default to soccer for backward compatibility
if not espn_id:
# No ESPN ID — fall back to web search for live score
team_key = team.get("short_name", team_name)
web_alerts = _web_search_live(team_name, team_key)
return web_alerts, state
leagues = team.get("espn_leagues", ["eng.1", "uefa.champions"])
match = find_team_match(espn_id, leagues, sport)
if not match:
return [], state
event = match["event"]
event_id = str(match["event_id"])
league = match["league"]
status = event.get("status", {}).get("type", {}).get("description", "")
# Get scores
competitions = event.get("competitions", [{}])[0]
competitors = competitions.get("competitors", [])
home_team = away_team = ""
home_score = away_score = 0
is_home = False
for c in competitors:
t_name = c.get("team", {}).get("displayName", "?")
t_id = str(c.get("team", {}).get("id", ""))
# Safely parse score - ESPN returns string, empty string, or int
raw_score = c.get("score", 0)
try:
score = int(raw_score) if raw_score not in (None, "", "-") else 0
except (ValueError, TypeError):
score = 0
if c.get("homeAway") == "home":
home_team, home_score = t_name, score
if t_id == espn_id:
is_home = True
else:
away_team, away_score = t_name, score
# Previous state for this match
prev = state.get(event_id, {})
prev_status = prev.get("status", "")
prev_events = set(prev.get("event_ids", []))
# Kick-off / Game start
if alerts_config.get("kickoff", True):
# Check if game is live (covers "In Progress", "First Half", "Second Half", etc.)
is_live = status in ("In Progress", "First Half", "Second Half", "1st Quarter", "2nd Quarter",
"3rd Quarter", "4th Quarter", "1st Period", "2nd Period", "3rd Period")
was_live = prev_status in ("In Progress", "First Half", "Second Half", "1st Quarter", "2nd Quarter",
"3rd Quarter", "4th Quarter", "1st Period", "2nd Period", "3rd Period")
if is_live and not was_live and prev_status != "Halftime":
from espn import LEAGUES
sport_leagues = LEAGUES.get(sport, {})
opponent = away_team if is_home else home_team
start_emoji = "🏟️" if sport == "soccer" else "🎮"
start_text = "KICK OFF!" if sport == "soccer" else "GAME ON!"
alerts.append(f"{start_emoji} **{start_text}** {team_emoji}\n{home_team} vs {away_team}\n{sport_leagues.get(league, league)}")
# Get detailed events
try:
key_events = get_match_events(event_id, sport, league)
except:
key_events = []
current_event_ids = []
for e in key_events:
e_id = str(e.get("id", e.get("clock", {}).get("value", "")))
current_event_ids.append(e_id)
if e_id in prev_events:
continue # Already seen
event_type = e.get("type", {}).get("text", "")
clock = e.get("clock", {}).get("displayValue", "?'")
e_team = e.get("team", {}).get("displayName", "")
participants = e.get("participants", [])
player = participants[0].get("athlete", {}).get("displayName", "Unknown") if participants else "Unknown"
is_our_team = team_name.lower() in e_team.lower()
# Scoring events (sport-specific)
if alerts_config.get("goals", True):
scored = False
score_line = f"{home_team} {home_score}-{away_score} {away_team}"
emoji = "🎉" if is_our_team else "😬"
if sport == "soccer":
if "Goal" in event_type:
scored = True
if "Own Goal" in event_type:
alerts.append(f"{emoji} **OWN GOAL!** {clock}\n⚽ {player} ({e_team})\n**{score_line}**")
elif "Penalty" in event_type:
alerts.append(f"{emoji} **PENALTY!** {clock}\n⚽ {player} ({e_team})\n**{score_line}**")
else:
alerts.append(f"{emoji} **GOAL!** {clock}\n⚽ {player} ({e_team})\n**{score_line}**")
elif sport == "football":
if "Touchdown" in event_type:
scored = True
alerts.append(f"{emoji} **TOUCHDOWN!** {clock}\n🏈 {player} ({e_team})\n**{score_line}**")
elif "Field Goal" in event_type:
scored = True
alerts.append(f"{emoji} **FIELD GOAL!** {clock}\n🎯 {player} ({e_team})\n**{score_line}**")
elif sport == "basketball":
if "Three Point" in event_type or "3PT" in event_type:
scored = True
alerts.append(f"{emoji} **3-POINTER!** {clock}\n🎯 {player} ({e_team})\n**{score_line}**")
elif sport == "hockey":
if "Goal" in event_type:
scored = True
alerts.append(f"{emoji} **GOAL!** {clock}\n🏒 {player} ({e_team})\n**{score_line}**")
elif sport == "baseball":
if "Home Run" in event_type or "HR" in event_type:
scored = True
alerts.append(f"{emoji} **HOME RUN!** {clock}\n⚾ {player} ({e_team})\n**{score_line}**")
# Red cards / Ejections (mainly for soccer and hockey)
if alerts_config.get("red_cards", True):
if sport == "soccer" and "Red" in event_type:
emoji = "😱" if is_our_team else "😈"
alerts.append(f"{emoji} 🟥 **RED CARD!** {clock}\n{player} ({e_team})")
elif sport == "hockey" and "Major Penalty" in event_type:
emoji = "😱" if is_our_team else "😈"
alerts.append(f"{emoji} **MAJOR PENALTY!** {clock}\n{player} ({e_team})")
# Halftime / Period breaks
if alerts_config.get("halftime", True):
if status == "Halftime" and prev_status != "Halftime":
alerts.append(f"⏸️ **HALFTIME** {team_emoji}\n{home_team} {home_score}-{away_score} {away_team}")
elif "End of" in status and "End of" not in prev_status:
# Basketball/Hockey period breaks
alerts.append(f"⏸️ **{status.upper()}** {team_emoji}\n{home_team} {home_score}-{away_score} {away_team}")
# Full time / Final
if alerts_config.get("fulltime", True):
if status == "Final" and prev_status not in ("Final", ""):
our_goals = home_score if is_home else away_score
their_goals = away_score if is_home else home_score
if our_goals > their_goals:
result_emoji = "🎉✅"
result = "WIN!"
elif our_goals < their_goals:
result_emoji = "😢❌"
result = "LOSS"
else:
result_emoji = "🤝"
result = "DRAW"
final_text = "FULL TIME" if sport == "soccer" else "FINAL"
score_str = f"{home_team} {home_score}-{away_score} {away_team}"
alerts.append(f"🏁 **{final_text} - {result}** {result_emoji} {team_emoji}\n{score_str}")
# Persist the final score to cache so ticker.py can show it later
team_key = team.get("short_name", team_name)
write_to_cache(team_key, score_str, source="espn")
# Update state
state[event_id] = {
"status": status,
"home_score": home_score,
"away_score": away_score,
"event_ids": current_event_ids
}
return alerts, state
def check_all_teams() -> list:
"""Check all configured teams for updates."""
all_alerts = []
state = load_state()
teams = get_teams()
alerts_config = get_alert_settings()
for team in teams:
alerts, state = check_team(team, state, alerts_config)
all_alerts.extend(alerts)
save_state(state)
return all_alerts
if __name__ == "__main__":
alerts = check_all_teams()
if alerts:
for alert in alerts:
print(alert)
print("---")
elif "--verbose" in sys.argv:
print("No live updates.")
#!/usr/bin/env python3
"""
Schedule - Show upcoming fixtures for configured teams.
Usage:
schedule.py # All teams, next 14 days
schedule.py --days 30 # All teams, next 30 days
schedule.py --team spurs # Specific team
schedule.py --team spurs --days 7 --json # JSON output
"""
import sys
import json
import argparse
from pathlib import Path
from datetime import datetime, timezone
sys.path.insert(0, str(Path(__file__).parent))
from espn import get_team_fixtures, format_fixture, LEAGUES
from config import get_teams, get_team_by_name
def get_team_schedule(team: dict, days: int = 14) -> list:
"""Get schedule for a single team."""
espn_id = team.get("espn_id")
if not espn_id:
return []
leagues = team.get("espn_leagues", ["eng.1"])
sport = team.get("sport", "soccer")
return get_team_fixtures(espn_id, days=days, sport=sport, leagues=leagues)
def format_team_schedule(team: dict, fixtures: list, compact: bool = False) -> str:
"""Format schedule output for a team."""
name = team.get("name", "Unknown")
emoji = team.get("emoji", "⚽")
lines = [f"{emoji} **{name}** - Upcoming Fixtures\n"]
if not fixtures:
lines.append("No fixtures found in the next period.")
else:
for f in fixtures:
if compact:
# Compact one-liner
date_str = f.get("date", "")
try:
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
date_fmt = dt.strftime("%d/%m %H:%M")
except:
date_fmt = date_str[:10]
loc = "vs" if f.get("is_home") else "@"
opp = f.get("opponent_short", f.get("opponent", "?"))
comp = f.get("competition", "")
lines.append(f" {date_fmt} {loc} {opp} ({comp})")
else:
lines.append(format_fixture(f, include_venue=True))
lines.append("")
return "\n".join(lines)
def all_teams_schedule(days: int = 14, compact: bool = False) -> str:
"""Get schedule for all configured teams."""
teams = get_teams()
if not teams:
return "No teams configured. Run setup.py first!"
parts = []
for team in teams:
if team.get("espn_id"):
fixtures = get_team_schedule(team, days)
parts.append(format_team_schedule(team, fixtures, compact))
if not parts:
return "No teams with ESPN IDs configured."
return ("\n" + "─" * 40 + "\n").join(parts)
def get_all_fixtures_json(days: int = 14, team_name: str = None) -> list:
"""Get all fixtures as JSON-serializable list."""
teams = get_teams()
all_fixtures = []
for team in teams:
if not team.get("espn_id"):
continue
# Filter by team name if specified
if team_name:
name_lower = team_name.lower()
if name_lower not in team.get("name", "").lower() and \
name_lower not in team.get("short_name", "").lower():
continue
fixtures = get_team_schedule(team, days)
for f in fixtures:
f["team_name"] = team.get("name")
f["team_short"] = team.get("short_name")
f["team_emoji"] = team.get("emoji", "⚽")
f["team_espn_id"] = team.get("espn_id")
all_fixtures.append(f)
# Sort all fixtures by date
all_fixtures.sort(key=lambda x: x.get("date", ""))
return all_fixtures
def main():
parser = argparse.ArgumentParser(description="Show upcoming fixtures for your teams")
parser.add_argument("--days", "-d", type=int, default=14,
help="Number of days to look ahead (default: 14)")
parser.add_argument("--team", "-t", type=str,
help="Filter by team name")
parser.add_argument("--compact", "-c", action="store_true",
help="Compact output (one line per fixture)")
parser.add_argument("--json", "-j", action="store_true",
help="Output as JSON")
args = parser.parse_args()
if args.json:
fixtures = get_all_fixtures_json(days=args.days, team_name=args.team)
print(json.dumps(fixtures, indent=2))
elif args.team:
team = get_team_by_name(args.team)
if team:
fixtures = get_team_schedule(team, days=args.days)
print(format_team_schedule(team, fixtures, compact=args.compact))
else:
print(f"Team '{args.team}' not found in config.")
sys.exit(1)
else:
print(all_teams_schedule(days=args.days, compact=args.compact))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Generate cron job configurations for sports-ticker skill.
Outputs JSON configurations that the agent can use with the cron tool.
Does NOT create crons directly — the agent handles that via platform tools.
Usage:
python3 setup_crons.py <telegram_id> <timezone>
python3 setup_crons.py 123456789 "Europe/London"
python3 setup_crons.py --list # Show example configs
"""
import sys
import json
from pathlib import Path
SKILL_DIR = Path(__file__).parent.parent
SCRIPT_DIR = SKILL_DIR / "scripts"
def get_cron_configs(telegram_id: str, timezone: str) -> list[dict]:
"""Generate cron job configurations for the agent to create."""
return [
{
"name": "football-match-check",
"description": "Daily morning check for team matches. Auto-updates live-ticker schedule when matches found.",
"schedule": {
"kind": "cron",
"expr": "0 9 * * *",
"tz": timezone
},
"sessionTarget": "isolated",
"payload": {
"kind": "agentTurn",
"message": f"Check if any configured teams play today.\n\nRun: python3 {SCRIPT_DIR}/ticker.py\n\nIf a match is found today:\n1. Send match info to Telegram\n2. Update the live-ticker cron schedule to run every 2 mins during match hours\n3. Set a reminder 5 mins before kickoff\n\nIf no match today, reply NO_REPLY"
},
"delivery": {
"mode": "announce",
"channel": "telegram",
"to": telegram_id,
"bestEffort": True
},
"enabled": True
},
{
"name": "sports-live-ticker",
"description": "Live match monitoring - runs every 2 mins during matches. Auto-scheduled by match-check.",
"schedule": {
"kind": "cron",
"expr": "*/2 * * * *",
"tz": timezone
},
"sessionTarget": "isolated",
"payload": {
"kind": "agentTurn",
"message": f"Run: python3 {SCRIPT_DIR}/live_monitor.py\n\nIf output contains alerts (goals, kick-off, full-time, red cards), format nicely and include in response.\nIf no alerts, reply NO_REPLY"
},
"delivery": {
"mode": "announce",
"channel": "telegram",
"to": telegram_id,
"bestEffort": False
},
"enabled": False
},
{
"name": "sports-reminder",
"description": "Pre-match reminder. Auto-scheduled by match-check.",
"schedule": {
"kind": "cron",
"expr": "0 14 * * *",
"tz": timezone
},
"sessionTarget": "main",
"payload": {
"kind": "systemEvent",
"text": "⏰ Match reminder: Your team plays soon! Check the schedule."
},
"enabled": False
}
]
def main():
if len(sys.argv) == 2 and sys.argv[1] == "--list":
crons = get_cron_configs("YOUR_TELEGRAM_ID", "Europe/London")
print(json.dumps(crons, indent=2))
return
if len(sys.argv) < 3:
print(__doc__)
print("\nExamples:")
print(" python3 setup_crons.py 123456789 'Europe/London'")
print(" python3 setup_crons.py --list")
sys.exit(1)
telegram_id = sys.argv[1]
timezone = sys.argv[2]
crons = get_cron_configs(telegram_id, timezone)
# Output JSON for the agent to use with cron tool
print(json.dumps({
"instructions": "Use the cron tool to create these jobs. Do NOT use subprocess or CLI commands.",
"crons": crons
}, indent=2))
# Save configs for reference
config_file = SKILL_DIR / "cron_configs.json"
with open(config_file, "w") as f:
json.dump(crons, f, indent=2)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
🏆 Sports Ticker - Interactive Setup Wizard
First-run onboarding that guides you through:
- Selecting your sports
- Picking your favorite teams/drivers
- Configuring alerts & preferences
- Setting quiet hours
Run: python3 scripts/setup.py
"""
import sys
import json
from pathlib import Path
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent))
from espn import search_team, SPORT_MAPPING
# Paths
SCRIPT_DIR = Path(__file__).parent
SKILL_DIR = SCRIPT_DIR.parent
CONFIG_FILE = SKILL_DIR / "config.json"
CONFIG_EXAMPLE = SKILL_DIR / "config.example.json"
# ═══════════════════════════════════════════════════════════════════════════════
# SPORT CONFIGURATIONS
# ═══════════════════════════════════════════════════════════════════════════════
SPORTS = {
"soccer": {
"name": "Soccer/Football",
"emoji": "⚽",
"espn_sport": "soccer",
"popular_teams": [
("Real Madrid", "86", ["esp.1", "uefa.champions"], "⚪"),
("Barcelona", "83", ["esp.1", "uefa.champions"], "🔵🔴"),
("Manchester United", "360", ["eng.1", "uefa.europa"], "🔴"),
("Liverpool", "364", ["eng.1", "uefa.champions"], "🔴"),
("Bayern Munich", "132", ["ger.1", "uefa.champions"], "🔴⚪"),
("Paris Saint-Germain", "160", ["fra.1", "uefa.champions"], "🔵🔴"),
("Juventus", "111", ["ita.1", "uefa.champions"], "⚪⚫"),
("Manchester City", "382", ["eng.1", "uefa.champions"], "🩵"),
("Chelsea", "363", ["eng.1", "uefa.europa"], "🔵"),
("Arsenal", "359", ["eng.1", "uefa.champions"], "🔴⚪"),
],
"leagues": {
"eng.1": "Premier League 🏴",
"esp.1": "La Liga 🇪🇸",
"ger.1": "Bundesliga 🇩🇪",
"ita.1": "Serie A 🇮🇹",
"fra.1": "Ligue 1 🇫🇷",
"uefa.champions": "Champions League 🏆",
"usa.1": "MLS 🇺🇸",
}
},
"nfl": {
"name": "NFL",
"emoji": "🏈",
"espn_sport": "football",
"popular_teams": [
("Kansas City Chiefs", "12", ["nfl"], "🔴🟡"),
("Dallas Cowboys", "6", ["nfl"], "⭐"),
("New England Patriots", "17", ["nfl"], "🔵🔴"),
("San Francisco 49ers", "25", ["nfl"], "🔴🟡"),
("Green Bay Packers", "9", ["nfl"], "💚🟡"),
("Philadelphia Eagles", "21", ["nfl"], "🦅"),
("Buffalo Bills", "2", ["nfl"], "🔵🔴"),
("Miami Dolphins", "15", ["nfl"], "🐬"),
("Las Vegas Raiders", "13", ["nfl"], "⚫"),
("Denver Broncos", "7", ["nfl"], "🧡🔵"),
],
"leagues": {"nfl": "NFL 🏈"}
},
"nba": {
"name": "NBA",
"emoji": "🏀",
"espn_sport": "basketball",
"popular_teams": [
("Los Angeles Lakers", "13", ["nba"], "💜💛"),
("Golden State Warriors", "9", ["nba"], "💙💛"),
("Boston Celtics", "2", ["nba"], "☘️"),
("Chicago Bulls", "4", ["nba"], "🐂"),
("Miami Heat", "14", ["nba"], "🔥"),
("Brooklyn Nets", "17", ["nba"], "⚫⚪"),
("New York Knicks", "18", ["nba"], "🔵🧡"),
("Phoenix Suns", "21", ["nba"], "🌞"),
("Dallas Mavericks", "6", ["nba"], "🐴"),
("Milwaukee Bucks", "15", ["nba"], "🦌"),
],
"leagues": {"nba": "NBA 🏀"}
},
"nhl": {
"name": "NHL",
"emoji": "🏒",
"espn_sport": "hockey",
"popular_teams": [
("Toronto Maple Leafs", "10", ["nhl"], "🍁"),
("New York Rangers", "4", ["nhl"], "🔵🔴"),
("Montreal Canadiens", "8", ["nhl"], "🔵⚪🔴"),
("Boston Bruins", "1", ["nhl"], "🐻"),
("Chicago Blackhawks", "4", ["nhl"], "🪶"),
("Edmonton Oilers", "22", ["nhl"], "🛢️"),
("Detroit Red Wings", "17", ["nhl"], "🐙"),
("Vegas Golden Knights", "37", ["nhl"], "⚔️"),
("Colorado Avalanche", "21", ["nhl"], "🏔️"),
("Pittsburgh Penguins", "5", ["nhl"], "🐧"),
],
"leagues": {"nhl": "NHL 🏒"}
},
"mlb": {
"name": "MLB",
"emoji": "⚾",
"espn_sport": "baseball",
"popular_teams": [
("New York Yankees", "10", ["mlb"], "⚾"),
("Los Angeles Dodgers", "19", ["mlb"], "💙"),
("Boston Red Sox", "2", ["mlb"], "🔴"),
("Chicago Cubs", "16", ["mlb"], "🐻"),
("Atlanta Braves", "15", ["mlb"], "🪓"),
("Houston Astros", "18", ["mlb"], "⭐"),
("San Francisco Giants", "26", ["mlb"], "🧡⚫"),
("St. Louis Cardinals", "24", ["mlb"], "🐦"),
("Philadelphia Phillies", "22", ["mlb"], "🔔"),
("San Diego Padres", "25", ["mlb"], "🟤🟡"),
],
"leagues": {"mlb": "MLB ⚾"}
},
"f1": {
"name": "Formula 1",
"emoji": "🏎️",
"espn_sport": "racing",
"popular_teams": [
("Red Bull Racing", "1", ["f1"], "🔵🔴"),
("Ferrari", "2", ["f1"], "🔴"),
("Mercedes", "3", ["f1"], "⚫🩵"),
("McLaren", "4", ["f1"], "🧡"),
("Aston Martin", "5", ["f1"], "💚"),
("Alpine", "6", ["f1"], "💙"),
("Williams", "7", ["f1"], "💙"),
("Haas", "8", ["f1"], "⚫🔴"),
("RB (AlphaTauri)", "9", ["f1"], "🔵"),
("Sauber (Stake)", "10", ["f1"], "💚"),
],
"leagues": {"f1": "Formula 1 🏎️"}
}
}
ALERT_TYPES = {
"live": {
"name": "Live Scores",
"emoji": "⚡",
"description": "Real-time alerts for every goal/score/major event"
},
"final": {
"name": "Final Only",
"emoji": "🏁",
"description": "Just the final results when games end"
},
"digest": {
"name": "Daily Digest",
"emoji": "📰",
"description": "One summary of all your teams' results each day"
}
}
# ═══════════════════════════════════════════════════════════════════════════════
# HELPER FUNCTIONS
# ═══════════════════════════════════════════════════════════════════════════════
def clear_screen():
"""Clear terminal screen."""
print("\033[2J\033[H", end="")
def print_header(title: str, emoji: str = "🏆"):
"""Print a fun header."""
width = 60
print()
print("═" * width)
print(f"{emoji} {title.upper()}")
print("═" * width)
print()
def print_divider():
"""Print a subtle divider."""
print("\n" + "─" * 50 + "\n")
def get_input(prompt: str, default: str = None) -> str:
"""Get user input with optional default."""
if default:
prompt = f"{prompt} [{default}]: "
else:
prompt = f"{prompt}: "
response = input(prompt).strip()
return response if response else default
def get_yes_no(prompt: str, default: bool = True) -> bool:
"""Get yes/no input."""
default_str = "Y/n" if default else "y/N"
response = input(f"{prompt} ({default_str}): ").strip().lower()
if not response:
return default
return response in ("y", "yes", "yeah", "yep", "si", "ja")
def get_time(prompt: str) -> str:
"""Get time input in HH:MM format."""
while True:
response = input(f"{prompt} (HH:MM, e.g. 22:00): ").strip()
if not response:
return None
try:
# Validate format
parts = response.split(":")
if len(parts) == 2:
h, m = int(parts[0]), int(parts[1])
if 0 <= h <= 23 and 0 <= m <= 59:
return f"{h:02d}:{m:02d}"
except:
pass
print("❌ Please enter time as HH:MM (e.g. 22:00 or 07:30)")
def select_multiple(prompt: str, options: list, show_numbers: bool = True) -> list:
"""Allow user to select multiple items from a list."""
print(f"\n{prompt}")
print("Enter numbers separated by commas, or 'all' for everything")
print()
for i, (key, name) in enumerate(options, 1):
print(f" [{i}] {name}")
print()
response = input("Your choices: ").strip().lower()
if response in ("all", "*", "a"):
return [key for key, _ in options]
selected = []
try:
nums = [int(x.strip()) for x in response.split(",") if x.strip()]
for n in nums:
if 1 <= n <= len(options):
selected.append(options[n-1][0])
except:
pass
return selected
# ═══════════════════════════════════════════════════════════════════════════════
# ONBOARDING STEPS
# ═══════════════════════════════════════════════════════════════════════════════
def step_welcome():
"""Welcome screen."""
clear_screen()
print("""
╔═══════════════════════════════════════════════════════════════╗
║ ║
║ 🏆 SPORTS TICKER 🏆 ║
║ ║
║ ⚽ 🏈 🏀 🏒 ⚾ 🏎️ ║
║ ║
║ Never miss a goal, touchdown, or checkered flag! ║
║ Let's set up your personalized sports alerts. ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
""")
input("Press ENTER to start... ")
def step_select_sports() -> list:
"""Step 1: Which sports do you follow?"""
print_header("Step 1: Pick Your Sports", "📺")
print("Which sports do you follow? (Select all that apply)\n")
options = [(k, f"{v['emoji']} {v['name']}") for k, v in SPORTS.items()]
selected = select_multiple("", options, show_numbers=True)
if not selected:
print("\n⚠️ You need to select at least one sport!")
return step_select_sports()
sport_names = [SPORTS[s]["emoji"] + " " + SPORTS[s]["name"] for s in selected]
print(f"\n✅ Great choices! You selected: {', '.join(sport_names)}")
input("\nPress ENTER to continue...")
return selected
def step_select_teams(sports: list) -> list:
"""Step 2: Pick teams for each sport."""
all_teams = []
for sport_key in sports:
sport = SPORTS[sport_key]
print_header(f"Step 2: Your {sport['name']} Teams", sport['emoji'])
print(f"Which {sport['name']} teams do you want to follow?\n")
print("Popular teams:\n")
# Show popular teams
options = []
for i, (name, espn_id, leagues, emoji) in enumerate(sport["popular_teams"], 1):
options.append((espn_id, f"{emoji} {name}"))
print(f" [{i:2}] {emoji} {name}")
print(f"\n [{len(options)+1}] 🔍 Search for another team")
print()
response = input("Your choices (comma-separated numbers): ").strip()
selected_ids = []
search_requested = False
try:
nums = [int(x.strip()) for x in response.split(",") if x.strip()]
for n in nums:
if n == len(options) + 1:
search_requested = True
elif 1 <= n <= len(options):
selected_ids.append(n - 1)
except:
pass
# Add selected popular teams
for idx in selected_ids:
name, espn_id, leagues, emoji = sport["popular_teams"][idx]
all_teams.append({
"name": name,
"short_name": name.split()[-1] if len(name.split()) > 1 else name,
"emoji": emoji,
"sport": sport["espn_sport"],
"espn_id": espn_id,
"espn_leagues": leagues,
"enabled": True
})
# Search for custom teams
if search_requested:
while True:
print()
team_name = input("🔍 Enter team name to search (or 'done'): ").strip()
if team_name.lower() in ("done", "d", ""):
break
print(f"\nSearching for '{team_name}'...\n")
results = search_team(team_name, sport["espn_sport"])
if results:
seen = set()
unique_results = []
for r in results[:10]: # Limit to 10
key = (r['id'], r['name'])
if key not in seen:
seen.add(key)
unique_results.append(r)
for i, r in enumerate(unique_results, 1):
print(f" [{i}] {r['name']} ({r['league_name']})")
pick = input("\nSelect team number (or skip): ").strip()
try:
idx = int(pick) - 1
if 0 <= idx < len(unique_results):
r = unique_results[idx]
emoji = input(f"Emoji for {r['name']} [{sport['emoji']}]: ").strip() or sport['emoji']
all_teams.append({
"name": r['name'],
"short_name": r['name'].split()[-1],
"emoji": emoji,
"sport": sport["espn_sport"],
"espn_id": r['id'],
"espn_leagues": [r['league']],
"enabled": True
})
print(f"✅ Added {r['name']}!")
except:
pass
else:
print("No results found. Try a different name.")
if not any(t["sport"] == sport["espn_sport"] for t in all_teams):
print(f"\n⚠️ No {sport['name']} teams selected. Skipping...")
else:
team_count = len([t for t in all_teams if t["sport"] == sport["espn_sport"]])
print(f"\n✅ {team_count} {sport['name']} team(s) added!")
input("\nPress ENTER to continue...")
return all_teams
def step_alert_preferences() -> dict:
"""Step 3: How do you want alerts?"""
print_header("Step 3: Alert Style", "🔔")
print("How do you want to receive alerts?\n")
for i, (key, info) in enumerate(ALERT_TYPES.items(), 1):
print(f" [{i}] {info['emoji']} {info['name']}")
print(f" {info['description']}\n")
choice = input("Your choice (1-3) [1]: ").strip() or "1"
try:
idx = int(choice) - 1
alert_style = list(ALERT_TYPES.keys())[idx]
except:
alert_style = "live"
selected = ALERT_TYPES[alert_style]
print(f"\n✅ {selected['emoji']} {selected['name']} it is!")
# Build alerts config based on choice
if alert_style == "live":
alerts = {
"goals": True,
"touchdowns": True,
"red_cards": True,
"halftime": True,
"fulltime": True,
"kickoff": True,
"mode": "live"
}
elif alert_style == "final":
alerts = {
"goals": False,
"touchdowns": False,
"red_cards": False,
"halftime": False,
"fulltime": True,
"kickoff": False,
"mode": "final"
}
else: # digest
alerts = {
"goals": False,
"touchdowns": False,
"red_cards": False,
"halftime": False,
"fulltime": False,
"kickoff": False,
"mode": "digest"
}
input("\nPress ENTER to continue...")
return alerts
def step_game_reminders() -> bool:
"""Step 4: Game-day reminders?"""
print_header("Step 4: Game-Day Reminders", "⏰")
print("Want a heads-up before your teams play?\n")
print(" 📅 Get notified 30 minutes before kick-off/tip-off")
print(" 🎮 Never miss the start of a game again!\n")
enabled = get_yes_no("Enable game-day reminders?", default=True)
if enabled:
print("\n✅ ⏰ You'll get a 30-minute heads-up before games!")
else:
print("\n👍 No problem, you'll just get live alerts.")
input("\nPress ENTER to continue...")
return enabled
def step_quiet_hours() -> dict:
"""Step 5: Quiet hours?"""
print_header("Step 5: Quiet Hours (Optional)", "🌙")
print("Some games run late! Set quiet hours to pause alerts.\n")
print("During quiet hours, alerts are saved and sent when you wake up.\n")
if not get_yes_no("Set up quiet hours?", default=False):
print("\n👍 No quiet hours - you'll get alerts 24/7!")
input("\nPress ENTER to continue...")
return None
print()
start = get_time("Quiet hours START")
end = get_time("Quiet hours END")
if start and end:
print(f"\n✅ 🌙 Quiet hours: {start} to {end}")
input("\nPress ENTER to continue...")
return {"start": start, "end": end}
print("\n👍 No quiet hours set.")
input("\nPress ENTER to continue...")
return None
def step_summary(config: dict):
"""Show final summary."""
print_header("Setup Complete!", "🎉")
print("Here's your configuration:\n")
# Teams by sport
teams_by_sport = {}
for team in config.get("teams", []):
sport = team.get("sport", "other")
if sport not in teams_by_sport:
teams_by_sport[sport] = []
teams_by_sport[sport].append(team)
for sport, teams in teams_by_sport.items():
sport_emoji = {"soccer": "⚽", "football": "🏈", "basketball": "🏀",
"hockey": "🏒", "baseball": "⚾", "racing": "🏎️"}.get(sport, "🏆")
print(f" {sport_emoji} {sport.upper()}")
for t in teams:
print(f" {t.get('emoji', '')} {t['name']}")
print()
# Alert style
mode = config.get("alerts", {}).get("mode", "live")
mode_display = {"live": "⚡ Live Scores", "final": "🏁 Final Only", "digest": "📰 Daily Digest"}
print(f" 🔔 Alerts: {mode_display.get(mode, mode)}")
# Reminders
if config.get("preferences", {}).get("game_reminders"):
print(" ⏰ Game-day reminders: ON")
# Quiet hours
quiet = config.get("preferences", {}).get("quiet_hours")
if quiet:
print(f" 🌙 Quiet hours: {quiet['start']} - {quiet['end']}")
print()
print("═" * 50)
print()
print(f"Config saved to: {CONFIG_FILE}")
print()
print("🚀 You're all set! Here's what you can do next:")
print()
print(" python3 scripts/ticker.py # Check your teams now")
print(" python3 scripts/live_monitor.py # Start live monitoring")
print(" python3 scripts/setup_crons.py # Set up auto-alerts")
print()
print("Enjoy the games! ⚽🏈🏀🏒⚾🏎️")
print()
# ═══════════════════════════════════════════════════════════════════════════════
# MAIN ONBOARDING FLOW
# ═══════════════════════════════════════════════════════════════════════════════
def run_onboarding():
"""Run the full onboarding wizard."""
try:
# Step 0: Welcome
step_welcome()
# Step 1: Select sports
selected_sports = step_select_sports()
# Step 2: Select teams for each sport
teams = step_select_teams(selected_sports)
if not teams:
print("\n❌ No teams configured. Please run setup again.")
return
# Step 3: Alert preferences
alerts = step_alert_preferences()
# Step 4: Game-day reminders
game_reminders = step_game_reminders()
# Step 5: Quiet hours
quiet_hours = step_quiet_hours()
# Build final config
config = {
"teams": teams,
"alerts": alerts,
"preferences": {
"game_reminders": game_reminders,
"quiet_hours": quiet_hours
},
"_setup_completed": datetime.now().isoformat()
}
# Save config
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2)
# Show summary
step_summary(config)
except KeyboardInterrupt:
print("\n\n👋 Setup cancelled. Run again anytime!")
sys.exit(0)
def find_team_id(team_name: str, sport: str = "soccer"):
"""Search for a team's ESPN ID."""
print(f"🔍 Searching for '{team_name}' in {sport}...\n")
results = search_team(team_name, sport)
if results:
seen = set()
print("Found:\n")
for r in results:
key = (r['id'], r['name'])
if key not in seen:
seen.add(key)
print(f" ESPN ID: {r['id']:6} | {r['name']:30} | {r['league_name']}")
else:
print("No teams found. Tips:")
print(" - Try the official team name (e.g., 'Manchester United')")
print(" - Check available leagues: python3 scripts/espn.py leagues [sport]")
print(" - Some lower leagues may not be indexed")
def check_first_run() -> bool:
"""Check if this is the first run (no config exists)."""
return not CONFIG_FILE.exists()
# ═══════════════════════════════════════════════════════════════════════════════
# CLI ENTRY POINT
# ═══════════════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
if len(sys.argv) < 2:
# No args - run onboarding (or interactive menu if config exists)
if check_first_run():
run_onboarding()
else:
print("🏆 Sports Ticker Setup\n")
print("Config already exists! Options:\n")
print(" 1. Run setup wizard again (overwrites config)")
print(" 2. Find a team ID")
print(" 3. Exit")
choice = input("\nChoice (1-3): ").strip()
if choice == "1":
confirm = get_yes_no("\n⚠️ This will replace your current config. Continue?", default=False)
if confirm:
run_onboarding()
else:
print("👍 Keeping existing config.")
elif choice == "2":
name = input("Team name: ").strip()
sport = input("Sport (soccer/football/basketball/hockey/baseball/racing) [soccer]: ").strip() or "soccer"
if name:
find_team_id(name, sport)
else:
print("👋 Bye!")
elif sys.argv[1] == "find":
# Find team ID
if len(sys.argv) < 3:
print("Usage: setup.py find <team_name> [sport]")
print("Sports: soccer, football, basketball, hockey, baseball, racing")
else:
args = sys.argv[2:]
sport = "soccer"
if args and args[-1] in SPORT_MAPPING:
sport = args[-1]
team_name = " ".join(args[:-1])
else:
team_name = " ".join(args)
find_team_id(team_name, sport)
elif sys.argv[1] == "--force":
# Force run onboarding
run_onboarding()
else:
print("🏆 Sports Ticker Setup")
print()
print("Usage:")
print(" setup.py - Interactive setup (or menu if config exists)")
print(" setup.py --force - Run setup wizard (even if config exists)")
print(" setup.py find <team> [sport] - Find team ESPN ID")
print()
print("Sports: soccer, football, basketball, hockey, baseball, racing")
#!/usr/bin/python3
"""
Sports Ticker - Display results and upcoming fixtures for your teams.
Includes web search fallback when ESPN returns no match.
"""
import sys
import os
import json
import urllib.request
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from espn import find_team_match, format_match, get_scoreboard, FOOTBALL_LEAGUES
from config import get_teams
from cache import write_to_cache, format_cached_result
def _load_key(env_var: str, env_file_key: str) -> str:
"""Load API key from env or web-search-plus .env file."""
key = os.environ.get(env_var, "")
if not key:
env_paths = [
Path(__file__).parent.parent.parent / "web-search-plus" / ".env",
Path(os.environ.get("HOME", "/root")) / "clawd/skills/web-search-plus/.env",
]
for ep in env_paths:
if ep.exists():
for line in ep.read_text().splitlines():
if env_file_key in line and "=" in line:
key = line.split("=", 1)[-1].strip().strip("'\"").lstrip("export ")
break
return key
def web_search_match(team_name: str, team_key: str = "") -> str:
"""Fallback: search for today's match/result. Brave first, Serper fallback.
If a result is found, it is written to the score cache under `team_key`
(defaults to `team_name` when not provided).
"""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
query = f"{team_name} match result {today}"
_cache_key = team_key or team_name
# 1. Try Brave Search (OpenClaw default) — only if key is set
brave_key = os.environ.get("BRAVE_SEARCH_API_KEY", "")
if brave_key:
try:
params = urllib.parse.urlencode({"q": query, "count": 3})
req = urllib.request.Request(
f"https://api.search.brave.com/res/v1/web/search?{params}",
headers={"Accept": "application/json", "X-Subscription-Token": brave_key},
)
data = json.loads(urllib.request.urlopen(req, timeout=10).read())
snippets = []
for r in data.get("web", {}).get("results", [])[:3]:
snippet = r.get("description", "")
title = r.get("title", "")
if snippet:
snippets.append(f"• {title}: {snippet[:120]}")
if snippets:
result_text = "🔍 Web:\n" + "\n".join(snippets)
write_to_cache(_cache_key, snippets[0], source="brave_search")
return result_text
except Exception:
pass # Fall through to Serper
# 2. Serper fallback
serper_key = _load_key("SERPER_API_KEY", "SERPER_API_KEY")
if serper_key:
try:
payload = json.dumps({"q": query, "num": 3}).encode()
req = urllib.request.Request(
"https://google.serper.dev/search",
data=payload,
headers={"X-API-KEY": serper_key, "Content-Type": "application/json"},
)
data = json.loads(urllib.request.urlopen(req, timeout=10).read())
snippets = []
for r in data.get("organic", [])[:3]:
snippet = r.get("snippet", "")
title = r.get("title", "")
if snippet:
snippets.append(f"• {title}: {snippet[:120]}")
if snippets:
result_text = "🔍 Web:\n" + "\n".join(snippets)
write_to_cache(_cache_key, snippets[0], source="serper_search")
return result_text
except Exception as e:
return f"🔍 Web search failed: {e}"
# 3. No API key — fall back to cache
cached = format_cached_result(_cache_key)
if cached:
return f"📦 {cached}"
return "🔍 No search API key — web fallback unavailable."
def team_ticker(team: dict) -> str:
"""Generate ticker for a single team."""
lines = []
name = team.get("name", "Unknown")
emoji = team.get("emoji", "⚽")
espn_id = team.get("espn_id")
leagues = team.get("espn_leagues", ["eng.1"])
sport = team.get("sport", "soccer")
team_key = team.get("short_name", name)
lines.append(f"{emoji} **{name}**\n")
if espn_id:
match = find_team_match(espn_id, leagues, sport)
if match:
result = format_match(match["event"], include_events=True,
sport=sport, league=match["league"])
lines.append(result)
# Cache the formatted result as well
write_to_cache(team_key, result, source="espn")
else:
# ESPN found nothing — web search fallback, then cache fallback
web_result = web_search_match(name, team_key)
lines.append(web_result)
# If web search found nothing and cache exists, show cached result
if "unavailable" in web_result or "failed" in web_result:
cached = format_cached_result(team_key)
if cached:
lines.append(f"\n📦 {cached}")
else:
# No ESPN ID — web search fallback, with cache backup
web_result = web_search_match(name, team_key)
lines.append(web_result)
if "unavailable" in web_result or "failed" in web_result:
cached = format_cached_result(team_key)
if cached:
lines.append(f"\n📦 {cached}")
return "\n".join(lines)
def all_teams_ticker() -> str:
"""Generate ticker for all configured teams."""
teams = get_teams()
if not teams:
return "No teams configured. Copy config.example.json to config.json and add your teams!"
parts = []
for team in teams:
parts.append(team_ticker(team))
if not parts:
return "No teams configured."
return ("\n" + "─" * 30 + "\n").join(parts)
def league_ticker(league: str = "eng.1", sport: str = "soccer") -> str:
"""Show all matches in a league."""
from espn import LEAGUES
sport_leagues = LEAGUES.get(sport, {})
lines = [f"**{sport_leagues.get(league, league)}**\n"]
data = get_scoreboard(sport, league)
events = data.get("events", [])
if not events:
lines.append("No matches in scoreboard.")
else:
for event in events:
lines.append(format_match(event, include_events=False, sport=sport, league=league))
lines.append("")
return "\n".join(lines)
if __name__ == "__main__":
if len(sys.argv) < 2:
print(all_teams_ticker())
else:
cmd = sys.argv[1]
if cmd == "league":
league = sys.argv[2] if len(sys.argv) > 2 else "eng.1"
sport = sys.argv[3] if len(sys.argv) > 3 else "soccer"
print(league_ticker(league, sport))
elif cmd == "all":
print(all_teams_ticker())
else:
teams = get_teams()
team_lower = cmd.lower()
for team in teams:
if team_lower in team.get("name", "").lower() or team_lower in team.get("short_name", "").lower():
print(team_ticker(team))
break
else:
print(f"Team '{cmd}' not found in config.")