feat: implement MCP server and dashboard for football data platform

Add complete Nike football data platform with:
- FastMCP server exposing football data tools over HTTP
- RapidAPI client for free-api-live-football-data integration
- Bootstrap web dashboard with live match/standings views
- REST API endpoints for dashboard consumption
- Docker support with multi-stage build
- Comprehensive README with architecture docs
- Minimal .gitignore replacing verbose Python template
This commit is contained in:
2026-03-21 18:19:42 +00:00
parent b8689d530a
commit ee8436d5b8
81 changed files with 50251 additions and 176 deletions

View File

@@ -0,0 +1,385 @@
# TheSportsDB API V1 — Validated Reference
**Validated on:** 2026-03-09 using free test key `3`
**Base URL:** `https://www.thesportsdb.com/api/v1/json/{key}`
**Auth:** Key embedded in URL path (free test key: `3`, premium: same Patreon key as V2)
**Source samples:** `docs/api_samples/sportsdb/`
> **This document covers ONLY what was tested and confirmed with real API calls.**
> V2 endpoints are NOT covered — they require a premium key and have not been tested.
---
## Free Key Limitations
The free key (`3`) returns **limited results** (typically 5 rows for standings, returns wrong/random events for some lookups). The response **structure and field names** are valid regardless — only row count and relevance are affected.
---
## Validated IDs
| Entity | TheSportsDB ID | Notes |
|--------|----------------|-------|
| MLS | `4346` | Listed as "American Major League Soccer" |
| English Premier League | `4328` | "English Premier League" |
| Toronto FC | `134148` | Short: `TOR`, league: 4346 |
| Arsenal | `133604` | Short: `ARS`, league: 4328 |
| Federico Bernardeschi | `34148472` | Position: Winger |
---
## Endpoints
### 1. Search Teams
```
GET /searchteams.php?t={team_name}
```
**Tested with:** `Toronto FC`, `Arsenal`
**Response wrapper:** `{"teams": [...]}`
**Returns:** Array of matching team objects (1 exact match each)
#### Validated fields (all values are strings or null):
| Field | Example (TFC) | Example (Arsenal) | Notes |
|-------|---------------|-------------------|-------|
| `idTeam` | `"134148"` | `"133604"` | String, not int |
| `idESPN` | `null` | `"359"` | Nullable |
| `idAPIfootball` | `"1601"` | `"42"` | Cross-ref to API-Football |
| `intLoved` | `"2"` | `"9"` | String of int |
| `strTeam` | `"Toronto FC"` | `"Arsenal"` | Full name |
| `strTeamAlternate` | `""` | `"Arsenal Football Club, AFC, Arsenal FC"` | Comma-separated aliases |
| `strTeamShort` | `"TOR"` | `"ARS"` | 3-letter code |
| `intFormedYear` | `"2006"` | `"1892"` | String of int |
| `strSport` | `"Soccer"` | `"Soccer"` | |
| `strLeague` | `"American Major League Soccer"` | `"English Premier League"` | Primary league |
| `idLeague` | `"4346"` | `"4328"` | String of int |
| `strLeague2`..`strLeague7` | `"Leagues Cup"` | `"FA Cup"`, `"EFL Cup"`, etc. | Additional leagues/cups |
| `idLeague2`..`idLeague7` | `"5281"` | `"4482"`, `"4570"`, etc. | IDs for above |
| `strDivision` | `null` | `null` | |
| `idVenue` | `"16782"` | `"15528"` | String of int |
| `strStadium` | `"BMO Field"` | `"Emirates Stadium"` | |
| `strKeywords` | `""` | `"Gunners, Gooners"` | Nicknames |
| `strRSS` | `""` | `""` | |
| `strLocation` | `"Toronto, Ontario"` | `"Holloway, London, England"` | |
| `intStadiumCapacity` | `"30991"` | `"60338"` | String of int |
| `strWebsite` | `"www.torontofc.ca"` | `"www.arsenal.com"` | No protocol |
| `strFacebook` | `"www.facebook.com/torontofc"` | `"www.facebook.com/Arsenal"` | |
| `strTwitter` | `"twitter.com/torontofc"` | `"twitter.com/arsenal"` | |
| `strInstagram` | `"www.instagram.com/torontofc"` | `"instagram.com/arsenal"` | |
| `strDescriptionEN` | Full paragraph | Full paragraph | English bio, `\r\n` separated |
| `strDescriptionDE`..`strDescriptionPL` | `null` or text | `null` or text | 14 language variants |
| `strColour1` | `"#B81137"` | `"#EF0107"` | Hex color |
| `strColour2` | `"#455560"` | `"#023474"` | |
| `strColour3` | `""` | `"#9C824A"` | May be empty |
| `strGender` | `"Male"` | `"Male"` | |
| `strCountry` | `"Canada"` | `"England"` | |
| `strBadge` | URL | URL | PNG badge |
| `strLogo` | URL | URL | PNG logo |
| `strFanart1`..`strFanart4` | URL or null | URL or null | Fan art images |
| `strBanner` | URL | URL | Banner image |
| `strEquipment` | URL | URL | Kit/equipment image |
| `strYoutube` | `"www.youtube.com/torontofc"` | `"www.youtube.com/ArsenalTour"` | |
| `strLocked` | `"unlocked"` | `"unlocked"` | |
**Key observations:**
- ALL numeric values are returned as **strings** (e.g., `"134148"` not `134148`)
- Team can belong to up to 7 leagues/cups
- `idAPIfootball` provides cross-reference to API-Football IDs
---
### 2. Lookup Team by ID
```
GET /lookupteam.php?id={idTeam}
```
**Tested with:** `134148` (Toronto FC) — but free key returned Arsenal instead (known free-key bug)
**Response wrapper:** `{"teams": [...]}`
**Fields:** Identical to search response above.
**⚠ Free key pitfall:** Lookup by ID returned the wrong team (Arsenal instead of TFC). This is a free-key limitation, not a field-structure issue. The response shape is the same as search.
---
### 3. Previous Team Events (Last Results)
```
GET /eventslast.php?id={idTeam}
```
**Tested with:** `134148` (Toronto FC)
**Response wrapper:** `{"results": [...]}` ← Note: key is `results`, NOT `events`
**Returns:** Last 5 results for the team (free key returned 1)
#### Validated event fields:
| Field | Example | Notes |
|-------|---------|-------|
| `idEvent` | `"2425216"` | String |
| `idAPIfootball` | `"1514800"` | Cross-ref, nullable |
| `strEvent` | `"Toronto FC vs Polissya Zhytomyr"` | Match title |
| `strEventAlternate` | `"Polissya Zhytomyr @ Toronto FC"` | Away @ Home format |
| `strFilename` | `"Club Friendlies 2026-02-14 Toronto FC vs Polissya Zhytomyr"` | League + date + teams |
| `strSport` | `"Soccer"` | |
| `idLeague` | `"4569"` | String — league of this match |
| `strLeague` | `"Club Friendlies"` | |
| `strLeagueBadge` | URL | League badge |
| `strSeason` | `"2026"` | or `"2025-2026"` for Euro leagues |
| `strDescriptionEN` | `""` | Match description (usually empty) |
| `strHomeTeam` | `"Toronto FC"` | |
| `strAwayTeam` | `"Polissya Zhytomyr"` | |
| `intHomeScore` | `"2"` | String of int, null if not played |
| `intAwayScore` | `"1"` | String of int, null if not played |
| `intRound` | `"0"` | String, `"0"` for friendlies |
| `intSpectators` | `null` | Nullable |
| `strOfficial` | `""` | Referee, often empty |
| `strTimestamp` | `"2026-02-14T09:45:00"` | ISO-ish, no timezone |
| `dateEvent` | `"2026-02-14"` | YYYY-MM-DD |
| `dateEventLocal` | `"2026-02-14"` | Local date |
| `strTime` | `"09:45:00"` | UTC time |
| `strTimeLocal` | `"04:45:00"` | Local time |
| `strGroup` | `""` | Group stage name, nullable |
| `idHomeTeam` | `"134148"` | String |
| `strHomeTeamBadge` | URL | Team badge |
| `idAwayTeam` | `"140180"` | String |
| `strAwayTeamBadge` | URL | Team badge |
| `intScore` | `null` | Aggregate/extra score? Always null in sample |
| `intScoreVotes` | `null` | Always null |
| `strResult` | `""` | Text result summary, empty |
| `idVenue` | `"16782"` | String |
| `strVenue` | `"BMO Field"` | |
| `strCountry` | `"Canada"` | |
| `strCity` | `""` | Often empty |
| `strPoster` | `""` | Image URLs, often empty |
| `strSquare` | `""` | |
| `strFanart` | `null` | |
| `strThumb` | `""` | |
| `strBanner` | `""` | |
| `strMap` | `null` | |
| `strTweet1` | `""` | |
| `strVideo` | `""` | Highlight video URL |
| `strStatus` | `"Match Finished"` | See status values below |
| `strPostponed` | `"no"` | `"no"` or `"yes"` |
| `strLocked` | `"unlocked"` | |
**Known `strStatus` values:** `"Match Finished"`, `"Not Started"`, `null` (old events)
---
### 4. Next Team Events (Upcoming)
```
GET /eventsnext.php?id={idTeam}
```
**Tested with:** `134148` (Toronto FC)
**Response wrapper:** `{"events": [...]}` ← Note: key is `events` (different from `eventslast`)
**Returns:** Next 515 upcoming events
**Fields:** Identical to the event structure in section 3 above, except:
- `intHomeScore` / `intAwayScore` = `null` (not yet played)
- `strStatus` = `"Not Started"`
- `dateEventLocal` / `strTimeLocal` may be `null`
- `strThumb` may have a URL (match preview image)
**⚠ Free key pitfall:** Free key returned Bolton Wanderers fixtures instead of Toronto FC fixtures. The event field structure is still valid.
---
### 5. Event Lookup by ID
```
GET /lookupevent.php?id={idEvent}
```
**Tested with:** `2425216`
**Response wrapper:** `{"events": [...]}`
**Returns:** Single event in array
**Fields:** Same event structure as section 3. All fields present.
**⚠ Free key pitfall:** Free key returned a completely different event (Liverpool vs Swansea 2014 instead of TFC vs Polissya 2026). The field structure is still valid — the values are just wrong.
---
### 6. Search Players
```
GET /searchplayers.php?p={player_name}
```
**Tested with:** `Bernardeschi`
**Response wrapper:** `{"player": [...]}` ← Note: key is `player`, singular
**Returns:** Array of matching players
#### Validated fields:
| Field | Example | Notes |
|-------|---------|-------|
| `idPlayer` | `"34148472"` | String |
| `idTeam` | `"134781"` | String |
| `strPlayer` | `"Federico Bernardeschi"` | Full name |
| `strTeam` | `"Bologna"` | Current team (may not match Toronto FC if transferred) |
| `strSport` | `"Soccer"` | |
| `strThumb` | URL | Player thumbnail |
| `strCutout` | URL | Player cutout image |
| `strNationality` | `"Italy"` | |
| `dateBorn` | `"1994-02-16"` | YYYY-MM-DD |
| `strStatus` | `"Active"` | `"Active"` or `"Retired"` |
| `strGender` | `"Male"` | |
| `strPosition` | `"Winger"` | |
| `relevance` | `"28.93..."` | Float as string, search relevance score |
**Note:** V1 player search returns a **minimal** field set (13 fields). V2 `lookup/player/{id}` is expected to return full bio, height, weight, description, etc. — **not yet tested**.
---
### 7. Standings / League Table
```
GET /lookuptable.php?l={idLeague}&s={season}
```
**Tested with:** `l=4328&s=2025-2026` (EPL), `l=4346&s=2026` (MLS)
**Response wrapper:** `{"table": [...]}`
**Returns:** Standings rows (free key: 5 rows; premium: full table)
#### Validated fields:
| Field | Example (EPL) | Example (MLS) | Notes |
|-------|---------------|---------------|-------|
| `idStanding` | `"8793837"` | `"8790287"` | String |
| `intRank` | `"1"` | `"1"` | String of int |
| `idTeam` | `"133604"` | `"150261"` | String |
| `strTeam` | `"Arsenal"` | `"San Diego FC"` | |
| `strBadge` | URL `/tiny` | URL `/tiny` | Tiny badge variant |
| `idLeague` | `"4328"` | `"4346"` | String |
| `strLeague` | `"English Premier League"` | `"American Major League Soccer"` | |
| `strSeason` | `"2025-2026"` | `"2026"` | Euro vs US format |
| `strForm` | `"WWWDD"` | `"WWW"` | Recent results W/D/L |
| `strDescription` | `"Promotion - Champions League..."` | `"Promotion - MLS (Play Offs...)"` | Qualification zone |
| `intPlayed` | `"30"` | `"3"` | String of int |
| `intWin` | `"20"` | `"3"` | |
| `intLoss` | `"3"` | `"0"` | |
| `intDraw` | `"7"` | `"0"` | |
| `intGoalsFor` | `"59"` | `"8"` | |
| `intGoalsAgainst` | `"22"` | `"0"` | |
| `intGoalDifference` | `"37"` | `"8"` | |
| `intPoints` | `"67"` | `"9"` | |
| `dateUpdated` | `"2026-03-09 06:01:21"` | `"2026-03-09 06:00:50"` | Last update timestamp |
**Season format:**
- European leagues: `"2025-2026"`
- MLS / calendar-year leagues: `"2026"`
**MLS standings note:** MLS has two conferences (East & West). The API returns **both mixed together** — ranks restart at `1` for each conference. There is no explicit conference field. You must use the rank patterns or `strDescription` to infer conference grouping.
---
### 8. Events by Date
```
GET /eventsday.php?d={YYYY-MM-DD}&s=Soccer # all soccer events
GET /eventsday.php?d={YYYY-MM-DD}&l={idLeague} # filtered by league
```
**Tested with:** `d=2026-03-09&s=Soccer`, `d=2026-03-09&l=4346`
**Response wrapper:** `{"events": [...]}` or `{"events": null}`
**Fields:** Same event structure as section 3.
**Results:**
- Global soccer on 2026-03-09: Returned **1 event** (Melbourne Victory vs Western Sydney, from 2014) — free key returns stale/random data
- MLS on 2026-03-09: Returned `{"events": null}` — no matches or free key limitation
**⚠ Free key:** Events-by-date is unreliable on free key. The field structure matches section 3 but the actual events returned are wrong/old. Premium key should return correct current-day events.
---
## Response Patterns Summary
| Endpoint | Wrapper Key | Null when empty |
|----------|-------------|-----------------|
| `searchteams.php` | `teams` | Unknown (always had results) |
| `lookupteam.php` | `teams` | Unknown |
| `eventslast.php` | `results` | Unknown |
| `eventsnext.php` | `events` | Unknown |
| `lookupevent.php` | `events` | Unknown |
| `searchplayers.php` | `player` | Unknown |
| `lookuptable.php` | `table` | Unknown |
| `eventsday.php` | `events` | **Yes** — returns `{"events": null}` |
---
## Data Type Warning
**All numeric fields are returned as strings.** Every `id*`, `int*`, and numeric value must be cast to `int` before use:
```python
team_id = int(team["idTeam"]) # "134148" → 134148
capacity = int(team["intStadiumCapacity"]) # "30991" → 30991
```
---
## V1 Endpoints NOT Tested
These V1 endpoints exist in the API but were not called during discovery:
| Endpoint | Purpose |
|----------|---------|
| `searchevents.php` | Search events by name |
| `eventsnextleague.php` | Next events for a league |
| `eventspastleague.php` | Previous events for a league |
| `eventsseason.php` | All events in a season |
| `lookup_all_players.php` | Full squad (V1 version) |
| `lookupplayer.php` | Detailed player profile |
| `all_sports.php` | All sports (used only for health check) |
| `all_leagues.php` | All leagues |
| `search_all_leagues.php` | Search leagues |
---
## V2 Endpoints — NOT TESTED (Premium Key Required)
These are the V2 endpoints built into `nike/sportsdb.py` but **entirely untested**. All field names in the code are guessed from TheSportsDB OpenAPI docs and have NOT been validated against real responses.
| Endpoint | Guessed wrapper key | What we don't know |
|----------|--------------------|--------------------|
| `GET /search/league/{name}` | `search` | Actual field names |
| `GET /search/team/{name}` | `search` | May differ from V1 |
| `GET /search/player/{name}` | `search` | May differ from V1 |
| `GET /lookup/event/{id}` | `lookup` or `events` | May have extra fields vs V1 |
| `GET /lookup/event_stats/{id}` | `lookup` | Field names like `strStat`, `intHome`, `intAway` are **guesses** |
| `GET /lookup/event_timeline/{id}` | `lookup` | Field names like `intTime`, `strTimeline`, `strPlayer` are **guesses** |
| `GET /lookup/event_lineup/{id}` | `lookup` | Fields like `intSquadNumber`, `strSubstitute`, `strHome` are **guesses** |
| `GET /lookup/player/{id}` | `lookup` or `players` | May have `strHeight`, `strWeight`, `strDescriptionEN` |
| `GET /lookup/player_contracts/{id}` | Unknown | Unknown |
| `GET /lookup/player_honours/{id}` | Unknown | Unknown |
| `GET /lookup/event_tv/{id}` | Unknown | Unknown |
| `GET /lookup/venue/{id}` | Unknown | Unknown |
| `GET /list/teams/{leagueId}` | `list` | Unknown |
| `GET /list/seasons/{leagueId}` | `list` | Unknown |
| `GET /list/players/{teamId}` | `list` | Field names for squad members unknown |
| `GET /schedule/next/league/{id}` | Unknown | Unknown |
| `GET /schedule/previous/league/{id}` | Unknown | Unknown |
| `GET /schedule/next/team/{id}` | Unknown | Unknown |
| `GET /schedule/previous/team/{id}` | Unknown | May match V1 `eventsnext` or differ |
| `GET /schedule/full/team/{id}` | Unknown | Unknown |
| `GET /schedule/league/{id}/{season}` | Unknown | Unknown |
| `GET /livescore/soccer` | `livescore` | All field names **guessed** |
---
## Next Steps (After Getting Premium Key)
1. Set `SPORTSDB_KEY=your_key` in `.env`
2. Run `python scripts/discover_sportsdb.py` — it auto-detects premium and tests all V2 endpoints
3. Review saved JSON files in `docs/api_samples/sportsdb/`
4. Update this document with V2 validated fields
5. Fix `sportsdb.py` client if any URL paths or wrapper keys are wrong
6. Design `schema.sql` from real field names (current schema is drafted from guesses)
7. Fix `server.py` tool formatters to use actual field names