feat: migrate from RapidAPI to TheSportsDB with SvelteKit dashboard
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 42s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m20s

- Replace free-api-live-football-data (RapidAPI) backend with TheSportsDB
- Add PostgreSQL cache layer for permanent data (teams, players, leagues, events)
- Replace Bootstrap dashboard with SvelteKit-based interactive dashboard
- Restructure MCP tools around TheSportsDB capabilities (get_team_info, get_roster, get_fixtures, get_standings, etc.)
- Expose tool registry via GET /api/tools so dashboard stays in sync
- Remove legacy modules and references (api_football, sync, RapidAPI env vars)
This commit is contained in:
2026-06-11 10:22:24 -04:00
parent cbfa4b1a47
commit 62af6727e6
54 changed files with 549 additions and 37158 deletions

View File

@@ -24,7 +24,3 @@ NIKE_TRUSTED_PROXY=*
# ── Followed teams ────────────────────────────────────────
# Comma-separated list of "Team Name:League Name" pairs.
NIKE_TEAMS=Toronto FC:MLS, Arsenal:Premier League
# ── Legacy (not active) ───────────────────────────────────
# NIKE_RAPIDAPI_KEY=
# NIKE_API_FOOTBALL_KEY=

115
README.md
View File

@@ -2,7 +2,7 @@
MCP server and web dashboard for football (soccer) data, with full MLS support.
Queries live data from [free-api-live-football-data](https://rapidapi.com/Creativesdev/api/free-api-live-football-data) (RapidAPI) and exposes it via MCP tools for conversational analysis and a Bootstrap status dashboard.
Queries live data from [TheSportsDB](https://www.thesportsdb.com/) and exposes it via MCP tools for conversational analysis, with a SvelteKit dashboard for status and interactive testing. A PostgreSQL cache stores permanent data (teams, players, leagues, events) to minimise API calls.
Project #205
---
@@ -13,61 +13,53 @@ Project #205
┌─────────────────────────────────────────────────────────┐
│ nike/server.py — single process on 0.0.0.0:{PORT} │
│ │
│ GET / → Bootstrap dashboard (dashboard.html)│
│ GET / → SvelteKit dashboard (dashboard/build)│
│ GET /api/* → Dashboard JSON API │
│ /mcp/ → FastMCP HTTP (streamable) │
└──────────┬──────────────────────────────────────────────┘
nike/rapidapi.py
(free-api-live-football-data client)
nike/sportsdb.py ←→ nike/db.py (PostgreSQL cache)
(TheSportsDB client)
RapidAPI
(free-api-live-football-data.p.rapidapi.com)
TheSportsDB API
```
### Module responsibilities
| Module | Role |
|--------|------|
| `nike/config.py` | Centralised settings from `.env` (API keys, constants) |
| `nike/rapidapi.py` | RapidAPI client with TTL cache (live data backend) |
| `nike/config.py` | Centralised settings from `.env` (API key, DB, server) |
| `nike/sportsdb.py` | TheSportsDB client with in-memory TTL cache (live backend) |
| `nike/db.py` | PostgreSQL connection pool + active cache layer for permanent data |
| `nike/server.py` | FastAPI app: MCP tools, dashboard routes, mounts MCP ASGI |
| `nike/templates/dashboard.html` | Live status dashboard (Bootstrap 5, dark theme) |
### Legacy modules (preserved, not active)
| Module | Role |
|--------|------|
| `nike/api_football.py` | API-Football v3 client (original backend) |
| `nike/db.py` | PostgreSQL connection pool and query helpers |
| `nike/sync.py` | API → DB sync pipeline |
| `schema.sql` | Database DDL |
| `schema.sql` | Cache database DDL |
### MCP Tools
| Tool | Description |
|------|-------------|
| `search(query)` | Universal search across teams, players, leagues, matches |
| `live_scores()` | All currently live matches worldwide |
| `fixtures(league, date)` | Matches by league and/or date |
| `standings(league)` | Full league table |
| `team_info(team)` | Team profile + squad roster |
| `player_info(player)` | Player profile and details |
| `match_detail(event_id)` | Full match: score, stats, lineups, venue, referee |
| `head_to_head(event_id)` | H2H history for a matchup |
| `top_players(league, stat)` | Top scorers / assists / rated |
| `transfers(league_or_team, scope)` | Transfer activity |
| `news(scope, name)` | Trending, league, or team news |
| `get_team_info(team_name)` | Team profile: stadium, capacity, location, founded year, colors |
| `get_roster(team_name)` | Current squad grouped by position |
| `get_player_info(player_name)` | Player profile: position, nationality, DOB, team, status |
| `get_fixtures(team_name, status)` | Recent results and upcoming matches (`status`: all/upcoming/past) |
| `get_standings(league, season)` | Full league table with points, goal difference, and form |
| `get_match_result(team_name, match_date)` | Match result for a team on a specific date |
| `get_match_detail(event_id)` ★ | Deep match stats, lineup, and timeline |
| `get_livescores()` ★ | Current live soccer scores worldwide |
★ Requires a premium TheSportsDB key.
The dashboard derives its tool list from these registrations via `GET /api/tools`, so it stays in sync automatically.
---
## Dashboard
The web dashboard is a SvelteKit 2 / Svelte 5 / Tailwind CSS 4 app in `dashboard/`.
The web dashboard is a SvelteKit 2 / Svelte 5 / Tailwind CSS 4 + DaisyUI 5 app in `dashboard/`.
| Route | Description |
|-------|-------------|
| `/` | System status: DB, API, MCP health cards; followed teams; tools list; request log |
| `/` | System status: cache, API, and MCP health cards; followed teams; tools list; request log |
| `/tools` | Interactive tool runner — pick a tool, fill in parameters, inspect raw output |
### Build (required before serving via FastAPI)
@@ -101,12 +93,12 @@ The dev dashboard is at `http://localhost:5173`.
### Prerequisites
- Python >= 3.11
- A RapidAPI key for [free-api-live-football-data](https://rapidapi.com/Creativesdev/api/free-api-live-football-data)
- PostgreSQL (for the cache; see `schema.sql`)
- A TheSportsDB key (`3` is the free test key; premium tools require a paid key)
### Install
```bash
cd ~/gitea/nike
python3 -m venv ~/env/nike
source ~/env/nike/bin/activate
pip install -e .
@@ -114,19 +106,15 @@ pip install -e .
### Configure
Create (or edit) `.env` in the project root:
Copy `.env.example` to `.env` and fill in your values (TheSportsDB key, database connection, server host/port). See `.env.example` for the full list of `NIKE_*` variables.
```env
RAPIDAPI_KEY=<your-rapidapi-key>
```
### Verify API connectivity
### Provision the cache database
```bash
python scripts/test_rapidapi.py
python scripts/apply_schema.py
```
This searches for MLS, fetches standings, finds Toronto FC, and pulls the squad roster.
This applies `schema.sql` to the PostgreSQL database configured in `.env`.
---
@@ -171,8 +159,6 @@ Once connected, you can ask questions like:
- *"What matches are live right now?"*
- *"Show the MLS standings"*
- *"Who plays for Toronto FC?"*
- *"Who's the MLS top scorer?"*
- *"Any transfer news for Inter Miami?"*
- *"Tell me about Federico Bernardeschi"*
---
@@ -181,12 +167,8 @@ Once connected, you can ask questions like:
| Script | Purpose |
|--------|---------|
| `scripts/test_rapidapi.py` | Verify RapidAPI connectivity and fetch sample data |
| `scripts/test_db.py` | Verify PostgreSQL connectivity (legacy) |
| `scripts/test_api.py` | Verify API-Football connectivity (legacy) |
| `scripts/apply_schema.py` | Apply database schema (legacy) |
| `scripts/pull_tfc.py` | Full TFC data sync via API-Football (legacy) |
| `scripts/verify_db.py` | Print DB row counts (legacy) |
| `scripts/apply_schema.py` | Apply `schema.sql` to provision the cache database |
| `scripts/discover_sportsdb.py` | Explore TheSportsDB API responses |
---
@@ -197,41 +179,26 @@ nike/
├── .env # Secrets (not committed)
├── pyproject.toml # Package metadata & dependencies
├── run.py # Entrypoint: python run.py
├── schema.sql # Database DDL (legacy)
├── schema.sql # Cache database DDL
├── nike.service # systemd unit file
├── nike/
│ ├── __init__.py
│ ├── config.py # Settings from .env
│ ├── rapidapi.py # RapidAPI client (active backend)
│ ├── api_football.py # API-Football v3 client (legacy)
│ ├── db.py # DB pool + queries (legacy)
── sync.py # API → DB sync logic (legacy)
│ ├── server.py # FastAPI + MCP server
│ └── templates/
│ └── dashboard.html # Status dashboard
│ ├── sportsdb.py # TheSportsDB client (live backend)
│ ├── db.py # PostgreSQL pool + active cache
│ ├── logging_config.py # Structured logging setup
── server.py # FastAPI + MCP server
├── dashboard/ # SvelteKit + DaisyUI dashboard
└── scripts/
├── test_rapidapi.py # RapidAPI smoke test
── apply_schema.py # (legacy)
├── pull_tfc.py # (legacy)
├── test_api.py # (legacy)
├── test_db.py # (legacy)
└── verify_db.py # (legacy)
├── apply_schema.py # Provision the cache database
── discover_sportsdb.py # API exploration
```
---
## API Quota
## Caching
The free-api-live-football-data RapidAPI pricing:
| Plan | Price | Requests/Month |
|------|-------|----------------|
| Basic (Free) | $0 | 100 |
| Pro | $9.99/mo | 20,000 |
| Ultra | $19.99/mo | 200,000 |
| Mega | $49.99/mo | 500,000 |
Nike uses a 5-minute in-memory TTL cache to minimize API calls during conversations.
Nike caches in two layers to minimise TheSportsDB calls: a short-lived in-memory TTL cache in `nike/sportsdb.py`, and a PostgreSQL cache (`nike/db.py`) for permanent data such as teams, players, leagues, and events. The dashboard's **Clear Cache** button (and `POST /api/cache/invalidate`) flushes both.

File diff suppressed because one or more lines are too long

View File

@@ -7,14 +7,12 @@
"": {
"name": "nike-dashboard",
"version": "0.1.0",
"dependencies": {
"@melt-ui/svelte": "^0.83.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3",
"daisyui": "^5",
"svelte": "^5.25.3",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3",
@@ -438,40 +436,11 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="
},
"node_modules/@internationalized/date": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
"integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
@@ -481,6 +450,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
@@ -490,6 +460,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"engines": {
"node": ">=6.0.0"
}
@@ -497,33 +468,19 @@
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@melt-ui/svelte": {
"version": "0.83.0",
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.83.0.tgz",
"integrity": "sha512-E7QT+8YSftz+Hdk1W0hNR3f+cnaF2COMWkStn+2u4vk0RO1I9mXRJl+bJD6uhYaH146oxEB+5elu/ABbv6rpsA==",
"dependencies": {
"@floating-ui/core": "^1.3.1",
"@floating-ui/dom": "^1.4.5",
"@internationalized/date": "^3.5.0",
"dequal": "^2.0.3",
"focus-trap": "^7.5.2",
"nanoid": "^5.0.4"
},
"peerDependencies": {
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.118"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -865,6 +822,7 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@@ -957,14 +915,6 @@
"vite": "^6.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.20",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz",
"integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
@@ -1231,17 +1181,20 @@
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true
},
"node_modules/@typescript-eslint/types": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -1254,6 +1207,7 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1265,6 +1219,7 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@@ -1273,6 +1228,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@@ -1296,6 +1252,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"engines": {
"node": ">=6"
}
@@ -1309,6 +1266,15 @@
"node": ">= 0.6"
}
},
"node_modules/daisyui": {
"version": "5.5.23",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.23.tgz",
"integrity": "sha512-xuheNUSL4T6ZVtWXoioqcNkjoyGX85QTDz4HTw2aBPfqk4fuMjax5HDo8qCmpV6M1YN8bGvfx5BpYCoDeRlt+A==",
"dev": true,
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1335,14 +1301,6 @@
"node": ">=0.10.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1355,7 +1313,8 @@
"node_modules/devalue": {
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"dev": true
},
"node_modules/enhanced-resolve": {
"version": "5.20.1",
@@ -1414,12 +1373,14 @@
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true
},
"node_modules/esrap": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz",
"integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==",
"dev": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
"@typescript-eslint/types": "^8.2.0"
@@ -1442,14 +1403,6 @@
}
}
},
"node_modules/focus-trap": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
"dependencies": {
"tabbable": "^6.4.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1474,6 +1427,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@@ -1748,12 +1702,14 @@
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
@@ -1782,23 +1738,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/nanoid": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz",
"integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1965,6 +1904,7 @@
"version": "5.55.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz",
"integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==",
"dev": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2010,11 +1950,6 @@
"typescript": ">=5.0.0"
}
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
@@ -2062,7 +1997,9 @@
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"optional": true
},
"node_modules/typescript": {
"version": "5.9.3",
@@ -2168,7 +2105,8 @@
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true
}
}
}

View File

@@ -14,13 +14,11 @@
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3",
"daisyui": "^5",
"svelte": "^5.25.3",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3",
"typescript": "^5.8.3",
"vite": "^6.2.5"
},
"dependencies": {
"@melt-ui/svelte": "^0.83.0"
}
}

View File

@@ -1,9 +1,19 @@
@import "tailwindcss";
/* Class-based dark mode: toggled via .dark on <html>. */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-pitch: #16a34a;
--color-pitch-dark: #15803d;
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
}
/* Keep the "pitch" green identity as the primary color in both themes. */
@plugin "daisyui/theme" {
name: "light";
default: true;
--color-primary: #15803d;
--color-primary-content: #ffffff;
}
@plugin "daisyui/theme" {
name: "dark";
prefersdark: true;
--color-primary: #16a34a;
--color-primary-content: #ffffff;
}

View File

@@ -5,13 +5,13 @@
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nike — Football Data Platform</title>
<!-- Apply theme before first paint to prevent flash of wrong theme. -->
<!-- Apply a manual theme override before first paint to prevent a flash.
With no override, daisyUI follows the system via its prefersdark media query. -->
<script>
try {
const stored = localStorage.getItem('nike-theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (stored !== 'light' && systemDark)) {
document.documentElement.classList.add('dark');
if (stored === 'dark' || stored === 'light') {
document.documentElement.setAttribute('data-theme', stored);
}
} catch (_) {}
</script>

View File

@@ -1,4 +1,4 @@
import type { LogsResponse, RunResult, StatusResponse } from './types';
import type { LogsResponse, RunResult, StatusResponse, ToolsResponse } from './types';
interface TelemetryReport {
type: string;
@@ -26,6 +26,12 @@ export async function fetchStatus(): Promise<StatusResponse> {
return r.json();
}
export async function fetchTools(): Promise<ToolsResponse> {
const r = await fetch('/api/tools');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
export async function fetchLogs(limit = 50): Promise<LogsResponse> {
const r = await fetch(`/api/logs?limit=${limit}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);

View File

@@ -29,11 +29,22 @@ export interface DataStatus {
followed: Array<{ team: string; league: string }>;
}
export interface Tool {
export interface ToolParam {
name: string;
type: string;
default: string | number | null;
required: boolean;
}
export interface ToolInfo {
name: string;
description: string;
readonly: boolean;
premium?: boolean;
premium: boolean;
params: ToolParam[];
}
export interface ToolsResponse {
tools: ToolInfo[];
}
export interface StatusResponse {
@@ -41,7 +52,6 @@ export interface StatusResponse {
api: ApiStatus;
mcp: McpStatus;
data: DataStatus;
tools: Tool[];
}
export interface LogEntry {

View File

@@ -13,9 +13,13 @@
// Derived: dark when explicitly set, otherwise follow system.
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
// Keep <html> class in sync with isDark whenever it changes.
// Apply a manual override via data-theme; clearing it lets daisyUI follow the system.
$effect(() => {
document.documentElement.classList.toggle('dark', isDark);
if (override === null) {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', override);
}
});
function toggleTheme() {
@@ -80,22 +84,19 @@
];
</script>
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<header class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10">
<div class="min-h-screen bg-base-200 text-base-content">
<header class="border-b border-base-300 bg-base-100/80 backdrop-blur sticky top-0 z-10">
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-green-500 text-lg leading-none"></span>
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Nike</span>
<span class="text-gray-500 text-sm hidden sm:inline">Football Data Platform</span>
<span class="text-primary text-lg leading-none"></span>
<span class="font-semibold tracking-tight">Nike</span>
<span class="text-base-content/50 text-sm hidden sm:inline">Football Data Platform</span>
</div>
<nav class="flex gap-1 ml-2">
{#each nav as item}
<a
href={item.href}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
item.href
? 'bg-green-700 text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
class="btn btn-sm {$page.url.pathname === item.href ? 'btn-primary' : 'btn-ghost'}"
>
{item.label}
</a>
@@ -103,7 +104,7 @@
</nav>
<button
onclick={toggleTheme}
class="ml-auto text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
class="btn btn-xs btn-outline rounded-full ml-auto"
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? 'Light' : 'Dark'}

View File

@@ -1,9 +1,10 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { fetchLogs, fetchStatus, invalidateCache } from '$lib/api';
import type { LogEntry, StatusResponse } from '$lib/types';
import { fetchLogs, fetchStatus, fetchTools, invalidateCache } from '$lib/api';
import type { LogEntry, StatusResponse, ToolInfo } from '$lib/types';
let status = $state<StatusResponse | null>(null);
let tools = $state<ToolInfo[]>([]);
let logs = $state<LogEntry[]>([]);
let loadError = $state<string | null>(null);
let invalidating = $state(false);
@@ -48,6 +49,10 @@
onMount(() => {
loadStatus();
loadLogs();
// Tool catalogue is static for the process lifetime — fetch once.
fetchTools()
.then((r) => (tools = r.tools))
.catch(() => {});
statusTimer = setInterval(loadStatus, 30_000);
logTimer = setInterval(loadLogs, 5_000);
});
@@ -57,10 +62,6 @@
clearInterval(logTimer);
});
function dot(connected: boolean | undefined) {
return connected ? 'bg-green-500 shadow-green-500/50 shadow-sm' : 'bg-red-500';
}
function fmtMs(ms: number | undefined | null) {
if (ms == null) return '—';
return `${ms.toFixed(0)} ms`;
@@ -78,232 +79,237 @@
const pairs = Object.entries(args).map(([k, v]) => `${k}=${JSON.stringify(v)}`);
return pairs.join(', ') || '—';
}
function firstLine(text: string) {
return text.split('\n').map((l) => l.trim()).find((l) => l) ?? '';
}
</script>
<div class="space-y-5">
<!-- Title row -->
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">System Status</h1>
<h1 class="text-lg font-semibold">System Status</h1>
<div class="flex items-center gap-3">
{#if invalidateMsg}
<span class="text-sm text-green-600 dark:text-green-400 transition-opacity">{invalidateMsg}</span>
<span class="text-sm text-primary transition-opacity">{invalidateMsg}</span>
{/if}
<button
onclick={doInvalidate}
disabled={invalidating}
class="px-3 py-1.5 rounded bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-300
border border-gray-300 dark:border-gray-700 disabled:opacity-50 transition-colors"
>
<button onclick={doInvalidate} disabled={invalidating} class="btn btn-sm btn-outline">
{invalidating ? 'Clearing…' : 'Clear Cache'}
</button>
</div>
</div>
{#if loadError}
<div class="p-4 rounded-lg bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-300 text-sm">
{loadError}
</div>
<div class="alert alert-error text-sm">{loadError}</div>
{/if}
{#if status}
<!-- Status cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Database -->
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3">
<!-- Cache (PostgreSQL) -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4 gap-3">
<div class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.database.connected)}"></span>
<span class="font-medium text-sm text-gray-900 dark:text-white">Database</span>
<span class="status {status.database.connected ? 'status-success' : 'status-error'}"></span>
<span class="font-medium text-sm">Cache (PostgreSQL)</span>
</div>
<dl class="text-sm space-y-1.5">
<div class="flex justify-between">
<dt class="text-gray-500">Host</dt>
<dd class="text-gray-700 dark:text-gray-200">{status.database.host ?? '—'}</dd>
<dt class="text-base-content/60">Host</dt>
<dd>{status.database.host ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Latency</dt>
<dd class="text-gray-700 dark:text-gray-200">{fmtMs(status.database.latency_ms)}</dd>
<dt class="text-base-content/60">Latency</dt>
<dd>{fmtMs(status.database.latency_ms)}</dd>
</div>
{#if status.database.version}
<div class="flex justify-between">
<dt class="text-gray-500">Version</dt>
<dd class="text-gray-500 dark:text-gray-400 text-xs font-mono truncate max-w-40">
<dt class="text-base-content/60">Version</dt>
<dd class="text-base-content/60 text-xs font-mono truncate max-w-40">
{status.database.version}
</dd>
</div>
{/if}
{#if status.database.error}
<div class="text-red-500 dark:text-red-400 text-xs pt-1">{status.database.error}</div>
<div class="text-error text-xs pt-1">{status.database.error}</div>
{/if}
</dl>
</div>
</div>
<!-- TheSportsDB -->
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3">
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4 gap-3">
<div class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.api.connected)}"></span>
<span class="font-medium text-sm text-gray-900 dark:text-white">TheSportsDB</span>
<span class="status {status.api.connected ? 'status-success' : 'status-error'}"></span>
<span class="font-medium text-sm">TheSportsDB</span>
</div>
<dl class="text-sm space-y-1.5">
<div class="flex justify-between">
<dt class="text-gray-500">Latency</dt>
<dd class="text-gray-700 dark:text-gray-200">{fmtMs(status.api.latency_ms)}</dd>
<dt class="text-base-content/60">Latency</dt>
<dd>{fmtMs(status.api.latency_ms)}</dd>
</div>
{#if status.api.backend}
<div class="flex justify-between">
<dt class="text-gray-500">Backend</dt>
<dd class="text-gray-700 dark:text-gray-200">{status.api.backend}</dd>
<dt class="text-base-content/60">Backend</dt>
<dd>{status.api.backend}</dd>
</div>
{/if}
{#if status.api.error}
<div class="text-red-500 dark:text-red-400 text-xs pt-1">{status.api.error}</div>
<div class="text-error text-xs pt-1">{status.api.error}</div>
{/if}
</dl>
</div>
</div>
<!-- MCP Server -->
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3">
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4 gap-3">
<div class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.mcp.running)}"></span>
<span class="font-medium text-sm text-gray-900 dark:text-white">MCP Server</span>
<span class="status {status.mcp.running ? 'status-success' : 'status-error'}"></span>
<span class="font-medium text-sm">MCP Server</span>
{#if status.mcp.premium}
<span
class="ml-auto text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800"
>
Premium
</span>
<span class="badge badge-warning badge-sm ml-auto">Premium</span>
{/if}
</div>
<dl class="text-sm space-y-1.5">
<div class="flex justify-between">
<dt class="text-gray-500">Transport</dt>
<dd class="text-gray-700 dark:text-gray-200">{status.mcp.transport}</dd>
<dt class="text-base-content/60">Transport</dt>
<dd>{status.mcp.transport}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Uptime</dt>
<dd class="text-gray-700 dark:text-gray-200">{status.mcp.uptime}</dd>
<dt class="text-base-content/60">Uptime</dt>
<dd>{status.mcp.uptime}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Tools</dt>
<dd class="text-gray-700 dark:text-gray-200">{status.mcp.tool_count}</dd>
<dt class="text-base-content/60">Tools</dt>
<dd>{status.mcp.tool_count}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500 shrink-0">Endpoint</dt>
<dd class="text-gray-500 dark:text-gray-400 text-xs font-mono truncate" title={status.mcp.endpoint}>
<dt class="text-base-content/60 shrink-0">Endpoint</dt>
<dd class="text-base-content/60 text-xs font-mono truncate" title={status.mcp.endpoint}>
{status.mcp.endpoint}
</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- Followed teams + MCP tools -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Followed teams -->
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider mb-1">
Followed Teams
</h2>
{#if status.data.followed.length === 0}
<p class="text-gray-500 text-sm">No teams configured (set NIKE_TEAMS in .env)</p>
<p class="text-base-content/60 text-sm">No teams configured (set NIKE_TEAMS in .env)</p>
{:else}
<ul class="space-y-2">
{#each status.data.followed as team}
<li class="flex items-center gap-2 text-sm">
<span class="text-green-500 text-xs"></span>
<span class="text-gray-900 dark:text-white">{team.team}</span>
<span class="text-gray-300 dark:text-gray-700">·</span>
<span class="text-gray-500 dark:text-gray-400">{team.league}</span>
<span class="text-primary text-xs"></span>
<span>{team.team}</span>
<span class="text-base-content/30">·</span>
<span class="text-base-content/60">{team.league}</span>
</li>
{/each}
</ul>
{/if}
{#if status.data.last_cache}
<p class="mt-3 text-xs text-gray-500">
<p class="mt-3 text-xs text-base-content/60">
Last cache update: {relTime(status.data.last_cache)}
</p>
{/if}
</div>
</div>
<!-- MCP Tools -->
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">MCP Tools</h2>
<table class="w-full text-sm">
<tbody class="divide-y divide-gray-100 dark:divide-gray-800/60">
{#each status.tools as tool}
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider mb-1">MCP Tools</h2>
<table class="table table-sm">
<tbody>
{#each tools as tool}
<tr>
<td class="py-1.5 pr-3">
<code class="text-green-600 dark:text-green-300 text-xs">{tool.name}</code>
<td class="align-top">
<code class="text-primary text-xs">{tool.name}</code>
{#if tool.premium}
<span
class="ml-1.5 text-xs px-1 rounded bg-amber-100 dark:bg-amber-900/50 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-800/50"
title="Requires premium TheSportsDB key"
class="badge badge-warning badge-xs ml-1 tooltip"
data-tip="Requires a premium TheSportsDB key"
>
</span>
{/if}
</td>
<td class="py-1.5 text-gray-500 dark:text-gray-400">{tool.description}</td>
<td class="text-base-content/60">{firstLine(tool.description)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<!-- DB table counts -->
{#if Object.keys(status.data.table_counts).length > 0}
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
Database Contents
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider mb-1">
Cache Contents
</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-4">
{#each Object.entries(status.data.table_counts) as [table, count]}
<div>
<div class="text-xs text-gray-500">{table}</div>
<div class="text-gray-900 dark:text-white font-mono text-sm mt-0.5">{count.toLocaleString()}</div>
<div class="stat p-0">
<div class="stat-title text-xs">{table}</div>
<div class="stat-value text-base font-mono">{count.toLocaleString()}</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
{:else if !loadError}
<div class="text-gray-500 text-sm animate-pulse">Loading status…</div>
<div class="flex items-center gap-2 text-base-content/60 text-sm">
<span class="loading loading-spinner loading-sm"></span> Loading status…
</div>
{/if}
<!-- Request Log -->
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
<div
class="px-4 py-3 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between"
>
<h2 class="text-sm font-medium text-gray-900 dark:text-white">Request Log</h2>
<span class="text-xs text-gray-500">{logs.length} entries · auto-refreshes every 5 s</span>
<div class="card bg-base-100 border border-base-300">
<div class="px-4 py-3 border-b border-base-300 flex items-center justify-between">
<h2 class="text-sm font-medium">Request Log</h2>
<span class="text-xs text-base-content/60">{logs.length} entries · auto-refreshes every 5 s</span>
</div>
{#if logs.length === 0}
<p class="px-4 py-5 text-gray-500 text-sm">No MCP requests yet.</p>
<p class="px-4 py-5 text-base-content/60 text-sm">No MCP requests yet.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<table class="table table-zebra table-sm">
<thead>
<tr class="text-left text-xs text-gray-500 dark:text-gray-600 border-b border-gray-200 dark:border-gray-800">
<th class="px-4 py-2 font-medium">Time</th>
<th class="px-4 py-2 font-medium">Tool</th>
<th class="px-4 py-2 font-medium">Args</th>
<th class="px-4 py-2 font-medium text-right">Duration</th>
<tr>
<th>Time</th>
<th>Tool</th>
<th>Args</th>
<th class="text-right">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100/40 dark:divide-gray-800/40">
<tbody>
{#each logs as entry}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors">
<td class="px-4 py-2 text-gray-500 whitespace-nowrap text-xs">
<tr>
<td class="text-base-content/60 whitespace-nowrap text-xs">
{relTime(entry.timestamp)}
</td>
<td class="px-4 py-2">
<code class="text-green-600 dark:text-green-300 text-xs">{entry.tool}</code>
<td>
<code class="text-primary text-xs">{entry.tool}</code>
</td>
<td class="px-4 py-2 text-gray-500 dark:text-gray-400 text-xs font-mono max-w-xs truncate">
<td class="text-base-content/60 text-xs font-mono max-w-xs truncate">
{fmtArgs(entry.args)}
</td>
<td class="px-4 py-2 text-right text-gray-500 whitespace-nowrap text-xs">
<td class="text-right text-base-content/60 whitespace-nowrap text-xs">
{entry.duration_ms} ms
</td>
</tr>

View File

@@ -1,98 +1,49 @@
<script lang="ts">
import { createTooltip, melt } from '@melt-ui/svelte';
import { runTool } from '$lib/api';
import { onMount } from 'svelte';
import { fetchTools, runTool } from '$lib/api';
import type { ToolInfo, ToolParam } from '$lib/types';
// ── Tool definitions ────────────────────────────────────
type ParamType = 'text' | 'number' | 'date' | 'select';
interface Param {
key: string;
label: string;
type: ParamType;
default: string;
// ── Frontend-only form niceties, keyed by backend param name ──
// The backend tool schema carries name/type/default/required; labels,
// placeholders, select options, and the input widget live here so the
// API stays presentation-free.
interface ParamUi {
label?: string;
input?: 'date' | 'select';
options?: string[];
placeholder?: string;
fallbackDefault?: string;
}
const PARAM_UI: Record<string, ParamUi> = {
team_name: { label: 'Team Name', placeholder: 'e.g. Arsenal, Toronto FC', fallbackDefault: 'Toronto FC' },
player_name: { label: 'Player Name', placeholder: 'e.g. Federico Bernardeschi' },
status: { label: 'Status Filter', input: 'select', options: ['all', 'upcoming', 'past'] },
match_date: { label: 'Date', input: 'date' },
event_id: { label: 'Event ID', placeholder: 'Get from get_fixtures first' },
league: { label: 'League', placeholder: 'e.g. English Premier League' },
season: { label: 'Season', placeholder: 'e.g. 2026 or 2025-2026' },
};
interface ToolDef {
name: string;
description: string;
premium: boolean;
params: Param[];
function uiFor(p: ToolParam): ParamUi {
return PARAM_UI[p.name] ?? {};
}
function labelFor(p: ToolParam) {
return uiFor(p).label ?? p.name;
}
function inputType(p: ToolParam): 'text' | 'number' | 'date' {
const ui = uiFor(p);
if (ui.input === 'date') return 'date';
return p.type === 'integer' ? 'number' : 'text';
}
function initialValue(p: ToolParam): string {
if (p.default != null) return String(p.default);
return uiFor(p).fallbackDefault ?? '';
}
const TOOLS: ToolDef[] = [
{
name: 'get_team_info',
description: 'Team profile: stadium, capacity, location, founded year, colors.',
premium: false,
params: [
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC', placeholder: 'e.g. Arsenal, Toronto FC' },
],
},
{
name: 'get_roster',
description: 'Current squad grouped by position. Requires premium key for live data.',
premium: false,
params: [
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
],
},
{
name: 'get_player_info',
description: 'Player profile: position, nationality, DOB, team, status.',
premium: false,
params: [
{ key: 'player_name', label: 'Player Name', type: 'text', default: '', placeholder: 'e.g. Federico Bernardeschi' },
],
},
{
name: 'get_fixtures',
description: 'Recent results and upcoming matches for a team.',
premium: false,
params: [
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
{ key: 'status', label: 'Status Filter', type: 'select', default: 'all', options: ['all', 'upcoming', 'past'] },
],
},
{
name: 'get_standings',
description: 'Full league table with points, goal difference, and form.',
premium: false,
params: [
{ key: 'league', label: 'League', type: 'text', default: 'American Major League Soccer', placeholder: 'e.g. English Premier League' },
{ key: 'season', label: 'Season', type: 'text', default: '2026', placeholder: 'e.g. 2026 or 2025-2026' },
],
},
{
name: 'get_match_result',
description: 'Match result for a team on a specific date.',
premium: false,
params: [
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
{ key: 'match_date', label: 'Date', type: 'date', default: '' },
],
},
{
name: 'get_match_detail',
description: 'Deep match stats, lineup, and timeline. Requires a premium TheSportsDB key.',
premium: true,
params: [
{ key: 'event_id', label: 'Event ID', type: 'number', default: '', placeholder: 'Get from get_fixtures first' },
],
},
{
name: 'get_livescores',
description: 'Current live soccer scores worldwide. Requires a premium TheSportsDB key.',
premium: true,
params: [],
},
];
// ── State ────────────────────────────────────────────────
let selectedTool = $state<ToolDef>(TOOLS[0]);
let tools = $state<ToolInfo[]>([]);
let selectedTool = $state<ToolInfo | null>(null);
let paramValues = $state<Record<string, string>>({});
let running = $state(false);
let result = $state<string | null>(null);
@@ -107,26 +58,30 @@
}
let history = $state<HistoryEntry[]>([]);
function selectTool(tool: ToolDef) {
function selectTool(tool: ToolInfo) {
selectedTool = tool;
result = null;
resultError = null;
paramValues = Object.fromEntries(tool.params.map((p) => [p.key, p.default]));
paramValues = Object.fromEntries(tool.params.map((p) => [p.name, initialValue(p)]));
}
// Init
selectTool(TOOLS[0]);
onMount(async () => {
const r = await fetchTools();
tools = r.tools;
if (tools.length > 0) selectTool(tools[0]);
});
async function submit() {
if (!selectedTool) return;
running = true;
result = null;
resultError = null;
const args: Record<string, unknown> = {};
for (const p of selectedTool.params) {
const val = paramValues[p.key];
const val = paramValues[p.name];
if (val === '') continue;
args[p.key] = p.type === 'number' ? Number(val) : val;
args[p.name] = p.type === 'integer' ? Number(val) : val;
}
const snapshot = { ...paramValues };
@@ -149,7 +104,7 @@
}
function loadHistory(entry: HistoryEntry) {
const tool = TOOLS.find((t) => t.name === entry.tool);
const tool = tools.find((t) => t.name === entry.tool);
if (!tool) return;
selectTool(tool);
// selectTool resets paramValues, restore after microtask
@@ -168,138 +123,124 @@
function fmtTime(d: Date) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
// Melt UI tooltip for premium badge
const {
elements: { trigger: premTrigger, content: premContent },
states: { open: premOpen },
} = createTooltip({ positioning: { placement: 'top' }, openDelay: 200 });
</script>
<div class="space-y-5">
<div>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Tool Runner</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
<h1 class="text-lg font-semibold">Tool Runner</h1>
<p class="text-sm text-base-content/60 mt-1">
Run MCP tools interactively and inspect raw API responses. Useful for spotting strange API
results.
</p>
</div>
{#if !selectedTool}
<div class="flex items-center gap-2 text-base-content/60 text-sm">
<span class="loading loading-spinner loading-sm"></span> Loading tools…
</div>
{:else}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
<!-- Left: selector + form + result -->
<div class="lg:col-span-2 space-y-4">
<!-- Tool selector -->
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Select Tool</h2>
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider mb-1">Select Tool</h2>
<div class="flex flex-wrap gap-2">
{#each TOOLS as tool}
{#each tools as tool}
<button
onclick={() => selectTool(tool)}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors
{selectedTool.name === tool.name
? 'bg-green-700 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700'}"
class="btn btn-sm {selectedTool.name === tool.name ? 'btn-primary' : 'btn-soft'}"
>
{tool.name}
{#if tool.premium}
<span use:melt={$premTrigger} class="ml-1 text-amber-500 dark:text-amber-400 cursor-help"></span>
<span
class="tooltip cursor-help text-warning"
data-tip="Requires a premium TheSportsDB key"
>
</span>
{/if}
</button>
{/each}
</div>
</div>
</div>
<!-- Parameter form -->
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-4">
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4 gap-4">
<div>
<div class="flex items-center gap-2">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white font-mono">{selectedTool.name}</h2>
<h2 class="text-sm font-semibold font-mono">{selectedTool.name}</h2>
{#if selectedTool.premium}
<span
class="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800/50"
>
Premium
</span>
<span class="badge badge-warning badge-sm">Premium</span>
{/if}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{selectedTool.description}</p>
<p class="text-xs text-base-content/60 mt-1 whitespace-pre-line">{selectedTool.description}</p>
</div>
{#if selectedTool.params.length > 0}
<div class="space-y-3">
{#each selectedTool.params as param}
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5" for={param.key}>
{param.label}
<label class="label text-xs font-medium mb-1" for={param.name}>
{labelFor(param)}
</label>
{#if param.type === 'select'}
{#if uiFor(param).input === 'select'}
<select
id={param.key}
bind:value={paramValues[param.key]}
class="w-full rounded-md bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-2 text-sm
text-gray-900 dark:text-gray-100 focus:outline-none focus:border-green-600 focus:ring-1
focus:ring-green-600"
id={param.name}
bind:value={paramValues[param.name]}
class="select select-sm w-full"
>
{#each param.options ?? [] as opt}
{#each uiFor(param).options ?? [] as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else}
<input
id={param.key}
type={param.type === 'number' ? 'number' : param.type === 'date' ? 'date' : 'text'}
placeholder={param.placeholder ?? ''}
bind:value={paramValues[param.key]}
class="w-full rounded-md bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-2 text-sm
text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:border-green-600
focus:ring-1 focus:ring-green-600"
id={param.name}
type={inputType(param)}
placeholder={uiFor(param).placeholder ?? ''}
bind:value={paramValues[param.name]}
class="input input-sm w-full"
/>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-xs text-gray-500 italic">No parameters — just click Run.</p>
<p class="text-xs text-base-content/60 italic">No parameters — just click Run.</p>
{/if}
<button
onclick={submit}
disabled={running}
class="px-4 py-2 rounded-md bg-green-700 hover:bg-green-600 text-white text-sm
font-medium disabled:opacity-50 transition-colors flex items-center gap-2"
>
<button onclick={submit} disabled={running} class="btn btn-primary btn-sm self-start">
{#if running}
<span class="inline-block w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
<span class="loading loading-spinner loading-xs"></span>
Running…
{:else}
Run Tool
{/if}
</button>
</div>
</div>
<!-- Result -->
{#if result !== null || resultError !== null}
<div class="card bg-base-100 border {resultError ? 'border-error' : 'border-base-300'}">
<div
class="rounded-lg bg-white dark:bg-gray-900 border {resultError
? 'border-red-200 dark:border-red-800'
: 'border-gray-200 dark:border-gray-800'}"
class="px-4 py-2.5 border-b {resultError ? 'border-error' : 'border-base-300'} flex items-center gap-2"
>
<div
class="px-4 py-2.5 border-b {resultError
? 'border-red-200 dark:border-red-800'
: 'border-gray-200 dark:border-gray-800'} flex items-center gap-2"
>
<span class="text-xs font-medium {resultError ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
<span class="text-xs font-medium {resultError ? 'text-error' : 'text-success'}">
{resultError ? 'Error' : 'Result'}
</span>
{#if result}
<span class="text-xs text-gray-500">
<span class="text-xs text-base-content/60">
{result.split('\n').length} lines
</span>
{/if}
</div>
<pre
class="px-4 py-4 text-sm font-mono whitespace-pre-wrap text-gray-700 dark:text-gray-200 overflow-x-auto
class="px-4 py-4 text-sm font-mono whitespace-pre-wrap overflow-x-auto
max-h-[520px] overflow-y-auto leading-relaxed"
>{resultError ?? result}</pre>
</div>
@@ -307,35 +248,35 @@
</div>
<!-- Right: session history -->
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 sticky top-20">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-800">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider">Session History</h2>
<div class="card bg-base-100 border border-base-300 sticky top-20">
<div class="px-4 py-3 border-b border-base-300">
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider">Session History</h2>
</div>
{#if history.length === 0}
<p class="px-4 py-5 text-sm text-gray-500">No queries yet.</p>
<p class="px-4 py-5 text-sm text-base-content/60">No queries yet.</p>
{:else}
<ul class="divide-y divide-gray-100 dark:divide-gray-800 max-h-[600px] overflow-y-auto">
<ul class="divide-y divide-base-300 max-h-[600px] overflow-y-auto">
{#each history as entry}
<li>
<button
onclick={() => loadHistory(entry)}
class="w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors"
>
<div class="flex items-center justify-between gap-2">
<code class="text-xs {entry.ok ? 'text-green-600 dark:text-green-300' : 'text-red-500 dark:text-red-400'} truncate">
<code class="text-xs {entry.ok ? 'text-primary' : 'text-error'} truncate">
{entry.tool}
</code>
<span class="text-xs text-gray-500 shrink-0">{fmtTime(entry.ts)}</span>
<span class="text-xs text-base-content/60 shrink-0">{fmtTime(entry.ts)}</span>
</div>
{#each Object.entries(entry.args) as [k, v]}
{#if v}
<div class="text-xs text-gray-500 truncate mt-0.5">
{k}: <span class="text-gray-500 dark:text-gray-400">{v}</span>
<div class="text-xs text-base-content/60 truncate mt-0.5">
{k}: <span class="text-base-content/80">{v}</span>
</div>
{/if}
{/each}
{#if !entry.ok}
<div class="text-xs text-red-500 mt-0.5 truncate">{entry.output}</div>
<div class="text-xs text-error mt-0.5 truncate">{entry.output}</div>
{/if}
</button>
</li>
@@ -344,14 +285,5 @@
{/if}
</div>
</div>
</div>
<!-- Melt UI tooltip for premium star -->
{#if $premOpen}
<div
use:melt={$premContent}
class="z-50 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2.5 py-1.5 text-xs text-amber-700 dark:text-amber-300 shadow-lg"
>
Requires a premium TheSportsDB key
</div>
{/if}
</div>

View File

@@ -1,77 +0,0 @@
{
"status": "success",
"response": {
"popular": [
{
"id": 47,
"name": "Premier League",
"localizedName": "Premier League",
"ccode": "ENG",
"logo": "https://images.fotmob.com/image_resources/logo/leaguelogo/dark/47.png"
},
{
"id": 42,
"name": "Champions League",
"localizedName": "Champions League",
"ccode": "INT",
"logo": "https://images.fotmob.com/image_resources/logo/leaguelogo/dark/42.png"
},
{
"id": 87,
"name": "LaLiga",
"localizedName": "LaLiga",
"ccode": "ESP",
"logo": "https://images.fotmob.com/image_resources/logo/leaguelogo/dark/87.png"
},
{
"id": 77,
"name": "World Cup",
"localizedName": "FIFA World Cup",
"ccode": "INT",
"logo": "https://images.fotmob.com/image_resources/logo/leaguelogo/dark/77.png"
},
{
"id": 54,
"name": "Bundesliga",
"localizedName": "Bundesliga",
"ccode": "GER",
"logo": "https://images.fotmob.com/image_resources/logo/leaguelogo/dark/54.png"
},
{
"id": 73,
"name": "Europa League",
"localizedName": "Europa League",
"ccode": "INT",
"logo": "https://images.fotmob.com/image_resources/logo/leaguelogo/dark/73.png"
},
{
"id": 53,
"name": "Ligue 1",
"localizedName": "Ligue 1",
"ccode": "FRA",
"logo": "https://images.fotmob.com/image_resources/logo/leaguelogo/dark/53.png"
},
{
"id": 55,
"name": "Serie A",
"localizedName": "Serie A",
"ccode": "ITA",
"logo": "https://images.fotmob.com/image_resources/logo/leaguelogo/dark/55.png"
},
{
"id": 138,
"name": "Copa del Rey",
"localizedName": "Copa del Rey",
"ccode": "ESP",
"logo": "https://images.fotmob.com/image_resources/logo/leaguelogo/dark/138.png"
},
{
"id": 132,
"name": "FA Cup",
"localizedName": "FA Cup",
"ccode": "ENG",
"logo": "https://images.fotmob.com/image_resources/logo/leaguelogo/dark/132.png"
}
]
}
}

View File

@@ -1,175 +0,0 @@
{
"status": "success",
"response": {
"suggestions": [
{
"type": "league",
"id": "47",
"score": 301131,
"name": "Premier League",
"ccode": "ENG"
},
{
"type": "league",
"id": "519",
"score": 300091,
"name": "Premier League",
"ccode": "EGY"
},
{
"type": "league",
"id": "9066",
"score": 300073,
"name": "Premier League",
"ccode": "TAN"
},
{
"type": "league",
"id": "63",
"score": 300071,
"name": "Premier League",
"ccode": "RUS"
},
{
"type": "league",
"id": "10028",
"score": 300058,
"name": "Premier League Qualification",
"ccode": "TAN"
},
{
"type": "league",
"id": "441",
"score": 300054,
"name": "Premier League",
"ccode": "UKR"
},
{
"type": "league",
"id": "522",
"score": 300054,
"name": "Premier League",
"ccode": "GHA"
},
{
"type": "league",
"id": "9084",
"score": 300050,
"name": "Premier League 2",
"ccode": "ENG"
},
{
"type": "league",
"id": "10176",
"score": 300041,
"name": "Premier League 2 Div 2",
"ccode": "ENG"
},
{
"type": "league",
"id": "10068",
"score": 300036,
"name": "Premier League U18",
"ccode": "ENG"
},
{
"type": "league",
"id": "263",
"score": 300024,
"name": "Premier League",
"ccode": "BLR"
},
{
"type": "league",
"id": "225",
"score": 300023,
"name": "Premier League",
"ccode": "KAZ"
},
{
"type": "league",
"id": "461",
"score": 300021,
"name": "Premier League",
"ccode": "SIN"
},
{
"type": "league",
"id": "9986",
"score": 300021,
"name": "Premier League",
"ccode": "CAN"
},
{
"type": "league",
"id": "262",
"score": 300020,
"name": "Premier League",
"ccode": "AZE"
},
{
"type": "league",
"id": "10443",
"score": 300019,
"name": "Premier League",
"ccode": "BAN"
},
{
"type": "league",
"id": "9829",
"score": 300018,
"name": "Premier League Qualification",
"ccode": "UKR"
},
{
"type": "league",
"id": "9333",
"score": 300017,
"name": "Premier League Qualification",
"ccode": "RUS"
},
{
"type": "league",
"id": "118",
"score": 300014,
"name": "Premier League",
"ccode": "ARM"
},
{
"type": "league",
"id": "267",
"score": 300013,
"name": "Premier League",
"ccode": "BIH"
},
{
"type": "league",
"id": "10783",
"score": 300011,
"name": "Women's Premier League",
"ccode": "KSA"
},
{
"type": "league",
"id": "9255",
"score": 300010,
"name": "Premier League qualification",
"ccode": "BLR"
},
{
"type": "league",
"id": "250",
"score": 300008,
"name": "Premier League",
"ccode": "FRO"
},
{
"type": "league",
"id": "529",
"score": 300008,
"name": "Premier League",
"ccode": "KUW"
}
]
}
}

View File

@@ -1,21 +0,0 @@
{
"status": "success",
"response": {
"suggestions": [
{
"type": "league",
"id": "130",
"score": 300491,
"name": "MLS",
"ccode": "USA"
},
{
"type": "league",
"id": "10282",
"score": 300014,
"name": "MLS Next Pro",
"ccode": "USA"
}
]
}
}

View File

@@ -1,79 +0,0 @@
{
"status": "success",
"response": {
"suggestions": [
{
"type": "team",
"id": "9825",
"score": 300993,
"name": "Arsenal",
"leagueId": 47,
"leagueName": "Premier League"
},
{
"type": "team",
"id": "258657",
"score": 300043,
"name": "Arsenal (W)",
"leagueId": 9227,
"leagueName": "WSL"
},
{
"type": "team",
"id": "950214",
"score": 300010,
"name": "Arsenal U21",
"leagueId": 9084,
"leagueName": "Premier League 2"
},
{
"type": "team",
"id": "1113566",
"score": 300008,
"name": "Arsenal U18",
"leagueId": 10068,
"leagueName": "Premier League U18"
},
{
"type": "team",
"id": "10098",
"score": 300001,
"name": "Arsenal Sarandi",
"leagueId": 9213,
"leagueName": "Primera B Metropolitana"
},
{
"type": "team",
"id": "1677",
"score": 300001,
"name": "Arsenal Tula",
"leagueId": 338,
"leagueName": "First League"
},
{
"type": "team",
"id": "1142489",
"score": 300000,
"name": "Arsenal Dzerzhinsk",
"leagueId": 263,
"leagueName": "Premier League"
},
{
"type": "team",
"id": "324771",
"score": 300000,
"name": "FK Arsenal Tivat",
"leagueId": 232,
"leagueName": "1. CFL"
},
{
"type": "team",
"id": "553807",
"score": 300000,
"name": "Arsenal Tula II",
"leagueId": 9123,
"leagueName": "Second League Division B Group 3"
}
]
}
}

View File

@@ -1,31 +0,0 @@
{
"status": "success",
"response": {
"suggestions": [
{
"type": "team",
"id": "56453",
"score": 300012,
"name": "Toronto FC",
"leagueId": 130,
"leagueName": "Major League Soccer"
},
{
"type": "team",
"id": "1022954",
"score": 300001,
"name": "Inter Toronto FC",
"leagueId": 9986,
"leagueName": "Premier League"
},
{
"type": "team",
"id": "614319",
"score": 300000,
"name": "Toronto FC II",
"leagueId": 10282,
"leagueName": "MLS Next Pro"
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
{
"status": "success",
"response": {
"matches": []
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,3 +0,0 @@
{
"message": "You have exceeded the MONTHLY quota for Requests on your current plan, BASIC. Upgrade your plan at https://rapidapi.com/Creativesdev/api/free-api-live-football-data"
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,827 +0,0 @@
{
"status": "success",
"response": {
"list": {
"squad": [
{
"title": "coach",
"members": [
{
"id": 308837,
"height": null,
"age": 59,
"dateOfBirth": "1966-12-17",
"name": "Robin Fraser",
"ccode": "USA",
"cname": "USA",
"role": {
"key": "coach",
"fallback": "Coach"
},
"excludeFromRanking": true
}
]
},
{
"title": "keepers",
"members": [
{
"id": 1715087,
"name": "Adisa De Rosario",
"shirtNumber": null,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "keeper_long",
"fallback": "Keeper"
},
"positionId": 0,
"injured": true,
"injury": {
"id": "35",
"expectedReturn": "Early April 2026"
},
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "11",
"positionIdsDesc": "GK",
"height": 185,
"age": 21,
"dateOfBirth": "2004-10-27",
"transferValue": 100000
},
{
"id": 1338704,
"name": "Luka Gavran",
"shirtNumber": 1,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "keeper_long",
"fallback": "Keeper"
},
"positionId": 0,
"injury": null,
"rating": 4.87,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "11",
"positionIdsDesc": "GK",
"height": 198,
"age": 25,
"dateOfBirth": "2000-05-09",
"transferValue": 242993
},
{
"id": 342699,
"name": "William Yarbrough",
"shirtNumber": 23,
"ccode": "USA",
"cname": "USA",
"role": {
"key": "keeper_long",
"fallback": "Keeper"
},
"positionId": 0,
"injury": null,
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "11",
"positionIdsDesc": "GK",
"height": 187,
"age": 36,
"dateOfBirth": "1989-03-20",
"transferValue": 50000
}
]
},
{
"title": "defenders",
"members": [
{
"id": 574923,
"name": "Benjam\u00edn Kuscevic",
"shirtNumber": null,
"ccode": "CHI",
"cname": "Chile",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injury": null,
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "34",
"positionIdsDesc": "CB",
"height": 186,
"age": 29,
"dateOfBirth": "1996-05-02",
"transferValue": 1483178
},
{
"id": 825691,
"name": "Henry Wingo",
"shirtNumber": 2,
"ccode": "USA",
"cname": "USA",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injured": true,
"injury": {
"id": "42",
"expectedReturn": "Early April 2026"
},
"rating": 6.14,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "71",
"positionIdsDesc": "RM",
"height": 183,
"age": 30,
"dateOfBirth": "1995-10-04",
"transferValue": 130182
},
{
"id": 1130753,
"name": "Zane Monlouis",
"shirtNumber": 12,
"ccode": "JAM",
"cname": "Jamaica",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injury": null,
"rating": 6.44,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "34",
"positionIdsDesc": "CB",
"height": 185,
"age": 22,
"dateOfBirth": "2003-10-16",
"transferValue": 502064
},
{
"id": 1346549,
"name": "Nicksoen Gomis",
"shirtNumber": 15,
"ccode": "FRA",
"cname": "France",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injured": true,
"injury": {
"id": "73",
"expectedReturn": "Late March 2026"
},
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "36,38",
"positionIdsDesc": "CB,LB",
"height": 185,
"age": 23,
"dateOfBirth": "2002-03-15",
"transferValue": 238195
},
{
"id": 1106934,
"name": "Kobe Franklin",
"shirtNumber": 19,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injury": null,
"rating": 5.44,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "32,38",
"positionIdsDesc": "RB,LB",
"height": 168,
"age": 22,
"dateOfBirth": "2003-05-10",
"transferValue": 240778
},
{
"id": 729506,
"name": "Richie Laryea",
"shirtNumber": 22,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injury": null,
"rating": 6.19,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "38,32,66,34",
"positionIdsDesc": "LB,RB,CDM,CB",
"height": 175,
"age": 31,
"dateOfBirth": "1995-01-07",
"transferValue": 470837
},
{
"id": 431956,
"name": "Walker Zimmerman",
"shirtNumber": 25,
"ccode": "USA",
"cname": "USA",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injury": null,
"rating": 5.51,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "34",
"positionIdsDesc": "CB",
"height": 191,
"age": 32,
"dateOfBirth": "1993-05-19",
"transferValue": 1163870
},
{
"id": 664764,
"name": "Raheem Edwards",
"shirtNumber": 44,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injury": null,
"rating": 6.88,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 1,
"excludeFromRanking": false,
"positionIds": "38,68",
"positionIdsDesc": "LB,LWB",
"height": 173,
"age": 30,
"dateOfBirth": "1995-07-17",
"transferValue": 246577
},
{
"id": 1261310,
"name": "Kosi Thompson",
"shirtNumber": 47,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injury": null,
"rating": 6.16,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "32,36,62",
"positionIdsDesc": "RB,CB,RWB",
"height": 178,
"age": 23,
"dateOfBirth": "2003-01-27",
"transferValue": 726678
},
{
"id": 1357562,
"name": "Adam Pearlman",
"shirtNumber": 51,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injury": null,
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "32,72,33",
"positionIdsDesc": "RB,RM,CB",
"height": 183,
"age": 20,
"dateOfBirth": "2005-04-05",
"transferValue": 571247
},
{
"id": 1504606,
"name": "Lazar Stefanovic",
"shirtNumber": 76,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injury": null,
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "37",
"positionIdsDesc": "CB",
"height": 187,
"age": 19,
"dateOfBirth": "2006-08-10",
"transferValue": 874785
},
{
"id": 1780464,
"name": "Stefan Kapor",
"shirtNumber": 98,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "defender_long",
"fallback": "Defender"
},
"positionId": 1,
"injury": null,
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": null,
"positionIdsDesc": null,
"height": null,
"age": 16,
"dateOfBirth": "2009-04-04",
"transferValue": null
}
]
},
{
"title": "midfielders",
"members": [
{
"id": 1187623,
"name": "Matheus Pereira",
"shirtNumber": 3,
"ccode": "BRA",
"cname": "Brazil",
"role": {
"key": "midfielder_long",
"fallback": "Midfielder"
},
"positionId": 2,
"injured": true,
"injury": {
"id": "47",
"expectedReturn": "Early April 2026"
},
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "78,107,59",
"positionIdsDesc": "LM,LW,LWB",
"height": 172,
"age": 25,
"dateOfBirth": "2000-12-21",
"transferValue": 1867261
},
{
"id": 1053698,
"name": "Jos\u00e9 Cifuentes",
"shirtNumber": 8,
"ccode": "ECU",
"cname": "Ecuador",
"role": {
"key": "midfielder_long",
"fallback": "Midfielder"
},
"positionId": 2,
"injury": null,
"rating": 6.83,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "64,76,86",
"positionIdsDesc": "CDM,CM,CAM",
"height": 178,
"age": 26,
"dateOfBirth": "1999-03-12",
"transferValue": 1895906
},
{
"id": 830601,
"name": "Djordje Mihailovic",
"shirtNumber": 10,
"ccode": "USA",
"cname": "USA",
"role": {
"key": "midfielder_long",
"fallback": "Midfielder"
},
"positionId": 2,
"injury": null,
"rating": 7.14,
"goals": 1,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "85,107,78,115",
"positionIdsDesc": "CAM,LW,LM,ST",
"height": 177,
"age": 27,
"dateOfBirth": "1998-11-10",
"transferValue": 6067551
},
{
"id": 1364471,
"name": "Alonso Coello",
"shirtNumber": 14,
"ccode": "ESP",
"cname": "Spain",
"role": {
"key": "midfielder_long",
"fallback": "Midfielder"
},
"positionId": 2,
"injury": null,
"rating": 6.23,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "73,66",
"positionIdsDesc": "CM,CDM",
"height": 185,
"age": 26,
"dateOfBirth": "1999-10-12",
"transferValue": 623924
},
{
"id": 432605,
"name": "Jonathan Osorio",
"shirtNumber": 21,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "midfielder_long",
"fallback": "Midfielder"
},
"positionId": 2,
"injury": null,
"rating": 7.07,
"goals": 0,
"penalties": 0,
"assists": 1,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "66,77,86,78",
"positionIdsDesc": "CDM,CM,CAM,LM",
"height": 175,
"age": 33,
"dateOfBirth": "1992-06-12",
"transferValue": 676957
},
{
"id": 1455365,
"name": "Markus Cimermancic",
"shirtNumber": 71,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "midfielder_long",
"fallback": "Midfielder"
},
"positionId": 2,
"injury": null,
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "76",
"positionIdsDesc": "CM",
"height": 175,
"age": 21,
"dateOfBirth": "2004-10-01",
"transferValue": 255919
},
{
"id": 1778338,
"name": "Malik Henry",
"shirtNumber": 78,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "midfielder_long",
"fallback": "Midfielder"
},
"positionId": 2,
"injury": null,
"rating": 6.44,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 1,
"excludeFromRanking": false,
"positionIds": "72,83",
"positionIdsDesc": "RM,RW",
"height": 163,
"age": 23,
"dateOfBirth": "2002-07-23",
"transferValue": 193874
}
]
},
{
"title": "attackers",
"members": [
{
"id": 1113737,
"name": "Theo Corbeanu",
"shirtNumber": 7,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "attacker_long",
"fallback": "Attacker"
},
"positionId": 3,
"injured": true,
"injury": {
"id": "14",
"expectedReturn": "Late April 2026"
},
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "87,83,104,72,84",
"positionIdsDesc": "LW,RW,ST,RM,CAM",
"height": 190,
"age": 23,
"dateOfBirth": "2002-05-17",
"transferValue": 1258500
},
{
"id": 848011,
"name": "Josh Sargent",
"shirtNumber": 9,
"ccode": "USA",
"cname": "USA",
"role": {
"key": "attacker_long",
"fallback": "Attacker"
},
"positionId": 3,
"injury": null,
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "115",
"positionIdsDesc": "ST",
"height": 185,
"age": 26,
"dateOfBirth": "2000-02-20",
"transferValue": 22347702
},
{
"id": 643482,
"name": "Derrick Etienne Jr.",
"shirtNumber": 11,
"ccode": "HAI",
"cname": "Haiti",
"role": {
"key": "attacker_long",
"fallback": "Attacker"
},
"positionId": 3,
"injury": null,
"rating": 6.71,
"goals": 1,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "115,87,79",
"positionIdsDesc": "ST,LW,LM",
"height": 178,
"age": 29,
"dateOfBirth": "1996-11-25",
"transferValue": 495791
},
{
"id": 1579304,
"name": "Emilio Aristiz\u00e1bal",
"shirtNumber": 17,
"ccode": "COL",
"cname": "Colombia",
"role": {
"key": "attacker_long",
"fallback": "Attacker"
},
"positionId": 3,
"injury": null,
"rating": 5.67,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "106",
"positionIdsDesc": "ST",
"height": 187,
"age": 20,
"dateOfBirth": "2005-08-05",
"transferValue": 1020000
},
{
"id": 655524,
"name": "D\u00e1niel Sall\u00f3i",
"shirtNumber": 20,
"ccode": "HUN",
"cname": "Hungary",
"role": {
"key": "attacker_long",
"fallback": "Attacker"
},
"positionId": 3,
"injury": null,
"rating": 6.1,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": false,
"positionIds": "107,78,83",
"positionIdsDesc": "LW,LM,RW",
"height": 185,
"age": 29,
"dateOfBirth": "1996-07-19",
"transferValue": 1598605
},
{
"id": 1338703,
"name": "Deandre Kerr",
"shirtNumber": 29,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "attacker_long",
"fallback": "Attacker"
},
"positionId": 3,
"injured": true,
"injury": {
"id": "87",
"expectedReturn": "Late March 2026"
},
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "115",
"positionIdsDesc": "ST",
"height": 180,
"age": 23,
"dateOfBirth": "2002-11-29",
"transferValue": 1095554
},
{
"id": 1276785,
"name": "Jules-Anthony Vilsaint",
"shirtNumber": 99,
"ccode": "CAN",
"cname": "Canada",
"role": {
"key": "attacker_long",
"fallback": "Attacker"
},
"positionId": 3,
"injury": null,
"rating": null,
"goals": 0,
"penalties": 0,
"assists": 0,
"rcards": 0,
"ycards": 0,
"excludeFromRanking": true,
"positionIds": "106",
"positionIdsDesc": "ST",
"height": 193,
"age": 23,
"dateOfBirth": "2003-01-06",
"transferValue": 286274
}
]
}
],
"isNationalTeam": false
}
}
}

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"message": "Request Failed Please try Again"
}

View File

@@ -1,3 +0,0 @@
{
"message": "You have exceeded the MONTHLY quota for Requests on your current plan, BASIC. Upgrade your plan at https://rapidapi.com/Creativesdev/api/free-api-live-football-data"
}

View File

@@ -1,347 +0,0 @@
{
"status": "success",
"response": {
"standing": [
{
"name": "Arsenal",
"shortName": "Arsenal",
"id": 9825,
"pageUrl": "/teams/9825/overview/arsenal",
"deduction": null,
"ongoing": null,
"played": 30,
"wins": 20,
"draws": 7,
"losses": 3,
"scoresStr": "59-22",
"goalConDiff": 37,
"pts": 67,
"idx": 1,
"qualColor": "#2AD572"
},
{
"name": "Manchester City",
"shortName": "Man City",
"id": 8456,
"pageUrl": "/teams/8456/overview/manchester-city",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 18,
"draws": 6,
"losses": 5,
"scoresStr": "59-27",
"goalConDiff": 32,
"pts": 60,
"idx": 2,
"qualColor": "#2AD572"
},
{
"name": "Manchester United",
"shortName": "Man United",
"id": 10260,
"pageUrl": "/teams/10260/overview/manchester-united",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 14,
"draws": 9,
"losses": 6,
"scoresStr": "51-40",
"goalConDiff": 11,
"pts": 51,
"idx": 3,
"qualColor": "#2AD572"
},
{
"name": "Aston Villa",
"shortName": "Aston Villa",
"id": 10252,
"pageUrl": "/teams/10252/overview/aston-villa",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 15,
"draws": 6,
"losses": 8,
"scoresStr": "39-34",
"goalConDiff": 5,
"pts": 51,
"idx": 4,
"qualColor": "#2AD572"
},
{
"name": "Chelsea",
"shortName": "Chelsea",
"id": 8455,
"pageUrl": "/teams/8455/overview/chelsea",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 13,
"draws": 9,
"losses": 7,
"scoresStr": "53-34",
"goalConDiff": 19,
"pts": 48,
"idx": 5,
"qualColor": "#0046A7"
},
{
"name": "Liverpool",
"shortName": "Liverpool",
"id": 8650,
"pageUrl": "/teams/8650/overview/liverpool",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 14,
"draws": 6,
"losses": 9,
"scoresStr": "48-39",
"goalConDiff": 9,
"pts": 48,
"idx": 6,
"qualColor": null
},
{
"name": "Brentford",
"shortName": "Brentford",
"id": 9937,
"pageUrl": "/teams/9937/overview/brentford",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 13,
"draws": 5,
"losses": 11,
"scoresStr": "44-40",
"goalConDiff": 4,
"pts": 44,
"idx": 7,
"qualColor": null
},
{
"name": "Everton",
"shortName": "Everton",
"id": 8668,
"pageUrl": "/teams/8668/overview/everton",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 12,
"draws": 7,
"losses": 10,
"scoresStr": "34-33",
"goalConDiff": 1,
"pts": 43,
"idx": 8,
"qualColor": null
},
{
"name": "AFC Bournemouth",
"shortName": "Bournemouth",
"id": 8678,
"pageUrl": "/teams/8678/overview/afc-bournemouth",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 9,
"draws": 13,
"losses": 7,
"scoresStr": "44-46",
"goalConDiff": -2,
"pts": 40,
"idx": 9,
"qualColor": null
},
{
"name": "Fulham",
"shortName": "Fulham",
"id": 9879,
"pageUrl": "/teams/9879/overview/fulham",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 12,
"draws": 4,
"losses": 13,
"scoresStr": "40-43",
"goalConDiff": -3,
"pts": 40,
"idx": 10,
"qualColor": null
},
{
"name": "Sunderland",
"shortName": "Sunderland",
"id": 8472,
"pageUrl": "/teams/8472/overview/sunderland",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 10,
"draws": 10,
"losses": 9,
"scoresStr": "30-34",
"goalConDiff": -4,
"pts": 40,
"idx": 11,
"qualColor": null
},
{
"name": "Newcastle United",
"shortName": "Newcastle",
"id": 10261,
"pageUrl": "/teams/10261/overview/newcastle-united",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 11,
"draws": 6,
"losses": 12,
"scoresStr": "42-43",
"goalConDiff": -1,
"pts": 39,
"idx": 12,
"qualColor": null
},
{
"name": "Crystal Palace",
"shortName": "Crystal Palace",
"id": 9826,
"pageUrl": "/teams/9826/overview/crystal-palace",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 10,
"draws": 8,
"losses": 11,
"scoresStr": "33-35",
"goalConDiff": -2,
"pts": 38,
"idx": 13,
"qualColor": null
},
{
"name": "Brighton & Hove Albion",
"shortName": "Brighton",
"id": 10204,
"pageUrl": "/teams/10204/overview/brighton-hove-albion",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 9,
"draws": 10,
"losses": 10,
"scoresStr": "38-36",
"goalConDiff": 2,
"pts": 37,
"idx": 14,
"qualColor": null
},
{
"name": "Leeds United",
"shortName": "Leeds",
"id": 8463,
"pageUrl": "/teams/8463/overview/leeds-united",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 7,
"draws": 10,
"losses": 12,
"scoresStr": "37-48",
"goalConDiff": -11,
"pts": 31,
"idx": 15,
"qualColor": null
},
{
"name": "Tottenham Hotspur",
"shortName": "Tottenham",
"id": 8586,
"pageUrl": "/teams/8586/overview/tottenham-hotspur",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 7,
"draws": 8,
"losses": 14,
"scoresStr": "39-46",
"goalConDiff": -7,
"pts": 29,
"idx": 16,
"qualColor": null
},
{
"name": "Nottingham Forest",
"shortName": "Nottm Forest",
"id": 10203,
"pageUrl": "/teams/10203/overview/nottingham-forest",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 7,
"draws": 7,
"losses": 15,
"scoresStr": "28-43",
"goalConDiff": -15,
"pts": 28,
"idx": 17,
"qualColor": null
},
{
"name": "West Ham United",
"shortName": "West Ham",
"id": 8654,
"pageUrl": "/teams/8654/overview/west-ham-united",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 7,
"draws": 7,
"losses": 15,
"scoresStr": "35-54",
"goalConDiff": -19,
"pts": 28,
"idx": 18,
"qualColor": "#FF4646"
},
{
"name": "Burnley",
"shortName": "Burnley",
"id": 8191,
"pageUrl": "/teams/8191/overview/burnley",
"deduction": null,
"ongoing": null,
"played": 29,
"wins": 4,
"draws": 7,
"losses": 18,
"scoresStr": "32-58",
"goalConDiff": -26,
"pts": 19,
"idx": 19,
"qualColor": "#FF4646"
},
{
"name": "Wolverhampton Wanderers",
"shortName": "Wolves",
"id": 8602,
"pageUrl": "/teams/8602/overview/wolverhampton-wanderers",
"deduction": null,
"ongoing": null,
"played": 30,
"wins": 3,
"draws": 7,
"losses": 20,
"scoresStr": "22-52",
"goalConDiff": -30,
"pts": 16,
"idx": 20,
"qualColor": "#FF4646"
}
]
}
}

View File

@@ -1,67 +0,0 @@
{
"status": "success",
"response": {
"players": [
{
"id": 737066,
"name": "Erling Haaland",
"teamId": 8456,
"teamName": "Manchester City",
"goals": 22,
"value": 22,
"stat": {
"name": "goals",
"value": 22,
"format": "number",
"fractions": 0
},
"teamColors": {
"darkMode": "#76b4e5",
"lightMode": "#69A8D8",
"fontDarkMode": "rgba(29, 29, 29, 1.0)",
"fontLightMode": "rgba(255, 255, 255, 1.0)"
}
},
{
"id": 1302005,
"name": "Igor Thiago",
"teamId": 9937,
"teamName": "Brentford",
"goals": 18,
"value": 18,
"stat": {
"name": "goals",
"value": 18,
"format": "number",
"fractions": 0
},
"teamColors": {
"darkMode": "#C00808",
"lightMode": "#C00808",
"fontDarkMode": "rgba(255, 255, 255, 1.0)",
"fontLightMode": "rgba(255, 255, 255, 1.0)"
}
},
{
"id": 933576,
"name": "Antoine Semenyo",
"teamId": 8456,
"teamName": "Manchester City",
"goals": 15,
"value": 15,
"stat": {
"name": "goals",
"value": 15,
"format": "number",
"fractions": 0
},
"teamColors": {
"darkMode": "#76b4e5",
"lightMode": "#69A8D8",
"fontDarkMode": "rgba(29, 29, 29, 1.0)",
"fontLightMode": "rgba(255, 255, 255, 1.0)"
}
}
]
}
}

View File

@@ -1,62 +0,0 @@
{
"status": "success",
"response": {
"news": [
{
"id": "ftbpro_01kk79jmekr0",
"imageUrl": "https://images2.minutemediacdn.com/image/upload/c_crop,w_1024,h_576,x_0,y_8/c_fill,w_912,h_516,f_auto,q_auto,g_auto/images/voltaxMediaLibrary/mmsport/si/01kk7af3bceybg72beqv.jpg",
"title": "Arsenal Need To Sell a Superstar: Four Players To Consider\u2014Ranked",
"gmtTime": "2026-03-08T23:00:00.000Z",
"sourceStr": "SI",
"sourceIconUrl": "https://images.fotmob.com/image_resources/news/si.png",
"page": {
"url": "/embed/news/01kk79jmekr0/arsenal-need-sell-superstar-four-players-considerranked"
}
},
{
"id": "ftbpro_01kk77nh7348",
"imageUrl": "https://images2.minutemediacdn.com/image/upload/c_crop,w_1919,h_1079,x_0,y_0/c_fill,w_912,h_516,f_auto,q_auto,g_auto/images/voltaxMediaLibrary/mmsport/si/01kk7b4hp7157m0sbm2f.jpg",
"title": "Transfer News, Rumors: Shock Zubimendi U-Turn Stings Man Utd; Real Madrid\u2019s Rodri Twist",
"gmtTime": "2026-03-09T00:05:00.000Z",
"sourceStr": "SI",
"sourceIconUrl": "https://images.fotmob.com/image_resources/news/si.png",
"page": {
"url": "/embed/news/01kk77nh7348/transfer-news-rumors-shock-zubimendi-u-turn-stings-man-utd-real-madrids-rodri-twist"
}
},
{
"id": "ftbpro_01khv73gpnbm",
"imageUrl": "https://images2.minutemediacdn.com/image/upload/c_crop,w_2141,h_1204,x_0,y_107/c_fill,w_912,h_516,f_auto,q_auto,g_auto/images/voltaxMediaLibrary/mmsport/si/01kj7qd6q1tmdh64dpn7.jpg",
"title": "Real Madrid\u2019s 10 Best Kits of All Time\u2014Ranked",
"gmtTime": "2026-03-08T22:00:00.000Z",
"sourceStr": "SI",
"sourceIconUrl": "https://images.fotmob.com/image_resources/news/si.png",
"page": {
"url": "/embed/news/01khv73gpnbm/real-madrids-10-best-kits-all-timeranked"
}
},
{
"id": "90hptfhwzvo61817ve6yo5cwr",
"imageUrl": "https://images.performgroup.com/di/library/omnisport/de/3d/pervis-estupinan_1vh3xc9744gyb15kz1mabotx3x.png?t=-741518868&w=520&h=300",
"title": "AC Milan 1-0 Inter: Estupinan strike settles Derby della Madonnina",
"gmtTime": "2026-03-08T22:55:22.000Z",
"sourceStr": "FotMob",
"sourceIconUrl": "https://images.fotmob.com/image_resources/news/fotmob.png",
"page": {
"url": "/news/90hptfhwzvo61817ve6yo5cwr-ac-milan-1-0-inter-estupinan-strike-settles-derby-della-madonnina"
}
},
{
"id": "ftbpro_01kk714gxhgj",
"imageUrl": "https://images2.minutemediacdn.com/image/upload/c_crop,w_1024,h_576,x_0,y_0/c_fill,w_912,h_516,f_auto,q_auto,g_auto/images/voltaxMediaLibrary/mmsport/si/01kk76ca6tg3gfyxsk6a.jpg",
"title": "'They Should Have Took Me'\u2014Tottenham Jibed By Record-Breaking English Manager",
"gmtTime": "2026-03-08T20:00:01.000Z",
"sourceStr": "SI",
"sourceIconUrl": "https://images.fotmob.com/image_resources/news/si.png",
"page": {
"url": "/embed/news/01kk714gxhgj/they-should-have-took-metottenham-jibed-record-breaking-english-manager"
}
}
]
}
}

View File

@@ -1,260 +0,0 @@
# Free API Live Football Data — API Reference
**Provider:** Sby Smart API (Creativesdev) on RapidAPI
**Base URL:** `https://free-api-live-football-data.p.rapidapi.com`
## Authentication
All requests require two headers:
```
x-rapidapi-host: free-api-live-football-data.p.rapidapi.com
x-rapidapi-key: YOUR_API_KEY
```
## Pricing
| Plan | Price | Requests/Month |
|------|-------|----------------|
| Basic (Free) | $0 | 100 |
| Pro | $9.99/mo | 20,000 |
| Ultra | $19.99/mo | 200,000 |
| Mega | $49.99/mo | 500,000 |
All plans include the same features/endpoints — only volume differs.
---
## Key IDs
- **leagueid** — League identifier (e.g., `42` for Premier League, `47` for another league). Use search or league list to find MLS ID.
- **teamid** — Team identifier (e.g., `8650`). Use team search or team list to find.
- **playerid** — Player identifier (e.g., `671529`). Use player search or squad list to find.
- **eventid** — Match/event identifier (e.g., `4621624`). Use fixtures or livescores to find.
---
## Endpoints
### Popular Leagues
```
GET /football-popular-leagues
```
### Countries
```
GET /football-get-all-countries
```
### Seasons
```
GET /football-league-all-seasons
```
---
### Livescores
```
GET /football-current-live
```
Returns all currently live matches worldwide with scores, status, and timing info.
---
### Fixtures
```
GET /football-get-matches-by-date?date={YYYYMMDD}
GET /football-get-matches-by-date-and-league?date={YYYYMMDD}
GET /football-get-all-matches-by-league?leagueid={leagueid}
```
---
### Leagues
```
GET /football-get-all-leagues
GET /football-get-all-leagues-with-countries
GET /football-get-league-detail?leagueid={leagueid}
GET /football-get-league-logo?leagueid={leagueid}
```
---
### Teams
```
GET /football-get-list-all-team?leagueid={leagueid}
GET /football-get-list-home-team?leagueid={leagueid}
GET /football-get-list-away-team?leagueid={leagueid}
GET /football-league-team?teamid={teamid}
GET /football-team-logo?teamid={teamid}
```
---
### Players / Athletes / Squad
```
GET /football-get-list-player?teamid={teamid}
GET /football-get-player-detail?playerid={playerid}
GET /football-get-player-logo?playerid={playerid}
```
---
### Events / Matches
```
GET /football-get-match-detail?eventid={eventid}
GET /football-get-match-score?eventid={eventid}
GET /football-get-match-status?eventid={eventid}
GET /football-get-match-highlights?eventid={eventid}
GET /football-get-match-location?eventid={eventid}
GET /football-get-match-all-stats?eventid={eventid}
GET /football-get-match-firstHalf-stats?eventid={eventid}
GET /football-get-match-secondhalf-stats?eventid={eventid}
GET /football-get-match-referee?eventid={eventid}
```
---
### Odds
```
GET /football-event-odds?eventid={eventid}&countrycode={CC}
GET /football-get-match-oddspoll?eventid={eventid}
GET /football-get-match-odds-voteresult?eventid={eventid}
```
---
### Statistics
```
GET /football-get-match-event-all-stats?eventid={eventid}
GET /football-get-match-event-firstHalf-stats?eventid={eventid}
GET /football-get-match-event-secondhalf-stats?eventid={eventid}
```
---
### Lineups
```
GET /football-get-hometeam-lineup?eventid={eventid}
GET /football-get-awayteam-lineup?eventid={eventid}
```
---
### Head to Head
```
GET /football-get-head-to-head?eventid={eventid}
```
---
### Standings
```
GET /football-get-standing-all?leagueid={leagueid}
GET /football-get-standing-home?leagueid={leagueid}
GET /football-get-standing-away?leagueid={leagueid}
```
---
### Rounds
```
GET /football-get-all-rounds?leagueid={leagueid}
GET /football-get-rounds-detail?roundid={roundid}
GET /football-get-rounds-players?leagueid={leagueid}
```
---
### Trophies
```
GET /football-get-trophies-all-seasons?leagueid={leagueid}
GET /football-get-trophies-detail?leagueid={leagueid}&season={season}
```
Season format example: `2023/2024` (URL-encoded as `2023%2F2024`)
---
### Top Players
```
GET /football-get-top-players-by-assists?leagueid={leagueid}
GET /football-get-top-players-by-goals?leagueid={leagueid}
GET /football-get-top-players-by-rating?leagueid={leagueid}
```
---
### Transfers
```
GET /football-get-all-transfers?page={page}
GET /football-get-top-transfers?page={page}
GET /football-get-market-value-transfers?page={page}
GET /football-get-league-transfers?leagueid={leagueid}
GET /football-get-team-contract-extension-transfers?teamid={teamid}
GET /football-get-team-players-in-transfers?teamid={teamid}
GET /football-get-team-players-out-transfers?teamid={teamid}
```
---
### News
```
GET /football-get-trendingnews
GET /football-get-league-news?leagueid={leagueid}&page={page}
GET /football-get-team-news?teamid={teamid}&page={page}
```
---
### Search
```
GET /football-all-search?search={query}
GET /football-teams-search?search={query}
GET /football-players-search?search={query}
GET /football-leagues-search?search={query}
GET /football-matches-search?search={query}
```
---
## Typical Workflow for MLS
1. **Find MLS league ID:** `GET /football-leagues-search?search=mls`
2. **Get Toronto FC team ID:** `GET /football-teams-search?search=toronto`
3. **Get upcoming fixtures:** `GET /football-get-all-matches-by-league?leagueid={MLS_ID}`
4. **Get live scores:** `GET /football-current-live` (filter for MLS matches)
5. **Get match details:** `GET /football-get-match-detail?eventid={eventid}`
6. **Get lineups:** `GET /football-get-hometeam-lineup?eventid={eventid}`
7. **Get match stats:** `GET /football-get-match-all-stats?eventid={eventid}`
8. **Get standings:** `GET /football-get-standing-all?leagueid={MLS_ID}`
---
## Notes
- Data appears to be sourced from FotMob based on field naming conventions and ID patterns.
- No external documentation exists — this reference was compiled from the RapidAPI playground.
- Date format for fixtures: `YYYYMMDD` (e.g., `20241107`)
- The "Statistics" category endpoints overlap with the match stats endpoints under "Events/Matches" — they may return the same or differently structured data. Test both.

View File

@@ -1,199 +0,0 @@
"""
API-Football v3 client (api-sports.io).
Docs: https://www.api-football.com/documentation-v3
Base: https://v3.football.api-sports.io
Auth: x-apisports-key header
All v3 responses follow: {"response": [...], "results": N, "paging": {...}}
"""
import time
import requests
from nike import config
_HEADERS = {
"x-apisports-key": config.API_FOOTBALL_KEY,
}
_last_remaining: int | None = None
# ── Internal HTTP helper ──────────────────────────────────
def _get(endpoint: str, params: dict | None = None, timeout: int = 15) -> dict:
"""GET {BASE}/{endpoint}?{params}. Raises on non-2xx."""
global _last_remaining
url = f"{config.API_FOOTBALL_BASE}/{endpoint}"
resp = requests.get(url, headers=_HEADERS, params=params, timeout=timeout)
resp.raise_for_status()
_last_remaining = resp.headers.get("x-ratelimit-requests-remaining")
data = resp.json()
# v3 reports errors in the body, not via HTTP status
errors = data.get("errors")
if errors and isinstance(errors, dict) and errors:
raise RuntimeError(f"API-Football error: {errors}")
return data
def _response(data: dict) -> list:
"""Extract the 'response' list from a v3 envelope."""
r = data.get("response", [])
return r if isinstance(r, list) else []
# ── Connectivity ──────────────────────────────────────────
def check_connection() -> dict:
"""Quick connectivity + quota probe via /status."""
try:
t0 = time.time()
data = _get("status", timeout=8)
latency_ms = round((time.time() - t0) * 1000, 1)
acct = data.get("response", {}).get("requests", {})
remaining = acct.get("limit_day", 100) - acct.get("current", 0)
return {
"connected": True,
"latency_ms": latency_ms,
"quota_remaining": remaining,
"quota_limit": acct.get("limit_day", 100),
}
except Exception as e:
return {"connected": False, "latency_ms": None,
"quota_remaining": None, "quota_limit": 100, "error": str(e)}
# ── Teams ─────────────────────────────────────────────────
def search_teams(name: str) -> list[dict]:
"""
GET /teams?search=<name>
Response items: {"team": {...}, "venue": {...}}
"""
data = _get("teams", {"search": name})
return _response(data)
def get_teams_by_league(league_id: int, season: int = 2025) -> list[dict]:
"""
GET /teams?league=<id>&season=<year>
"""
data = _get("teams", {"league": league_id, "season": season})
return _response(data)
# ── Squad / Players ───────────────────────────────────────
def get_squad(team_id: int) -> list[dict]:
"""
GET /players/squads?team=<id>
Response: [{"team": {...}, "players": [{id, name, number, position, photo}, ...]}]
Returns flat list of player dicts.
"""
data = _get("players/squads", {"team": team_id})
rows = _response(data)
if not rows:
return []
# First item contains the squad
return rows[0].get("players", [])
def get_player_detail(player_id: int, season: int = 2025) -> dict:
"""
GET /players?id=<id>&season=<year>
"""
data = _get("players", {"id": player_id, "season": season})
rows = _response(data)
return rows[0] if rows else {}
# ── Fixtures ──────────────────────────────────────────────
def get_fixtures(team_id: int, season: int, league_id: int) -> list[dict]:
"""
GET /fixtures?team=<id>&season=<year>&league=<id>
Response items:
{"fixture": {...}, "league": {...}, "teams": {...}, "goals": {...}, ...}
"""
data = _get("fixtures", {
"team": team_id,
"season": season,
"league": league_id,
})
return _response(data)
# ── Standings ─────────────────────────────────────────────
def get_standings(league_id: int, season: int) -> list[dict]:
"""
GET /standings?league=<id>&season=<year>
Response: [{"league": {"standings": [[{...}, ...]]}}]
Returns flat list of standing rows.
"""
data = _get("standings", {"league": league_id, "season": season})
rows = _response(data)
if not rows:
return []
# Nested: response[0].league.standings is a list-of-lists (conference groups)
league_data = rows[0].get("league", {})
standings = league_data.get("standings", [])
# Flatten groups
flat: list[dict] = []
for group in standings:
if isinstance(group, list):
flat.extend(group)
elif isinstance(group, dict):
flat.append(group)
return flat
# ── Player season stats ───────────────────────────────────
def get_player_stats(player_id: int, season: int, league_id: int) -> list[dict]:
"""
GET /players?id=<id>&season=<year>&league=<id>
"""
data = _get("players", {
"id": player_id,
"season": season,
"league": league_id,
})
return _response(data)
# ── Live fixtures ─────────────────────────────────────────
def get_live_fixtures(team_id: int) -> list[dict]:
"""
GET /fixtures?live=all&team=<id>
"""
try:
data = _get("fixtures", {"live": "all", "team": team_id}, timeout=10)
return _response(data)
except Exception:
return []
# ── Match score ───────────────────────────────────────────
def get_match_score(fixture_id: int) -> dict:
"""
GET /fixtures?id=<id>
"""
data = _get("fixtures", {"id": fixture_id})
rows = _response(data)
return rows[0] if rows else {}
# ── Utility ───────────────────────────────────────────────
def last_quota_remaining() -> int | None:
"""Last known remaining daily quota from response headers."""
try:
return int(_last_remaining) if _last_remaining is not None else None
except (ValueError, TypeError):
return None

View File

@@ -28,10 +28,6 @@ FOLLOWED_TEAMS = [
if ':' in pair
]
# ── Legacy API keys (preserved, not active) ───────────────
RAPIDAPI_KEY = os.getenv('NIKE_RAPIDAPI_KEY', '')
API_FOOTBALL_KEY = os.getenv('NIKE_API_FOOTBALL_KEY', '')
# ── Server ────────────────────────────────────────────────
SERVER_HOST = os.getenv('NIKE_HOST')
SERVER_PORT = int(os.getenv('NIKE_PORT'))

View File

@@ -1,356 +0,0 @@
"""
RapidAPI client for free-api-live-football-data.
Provider : Sby Smart API (Creativesdev) on RapidAPI
Base URL : https://free-api-live-football-data.p.rapidapi.com
Auth : x-rapidapi-key + x-rapidapi-host headers
All functions return parsed Python dicts/lists (or raise on error).
A lightweight TTL cache prevents duplicate API calls within a conversation.
"""
from __future__ import annotations
import time
from typing import Any
import requests
from nike import config
# ── HTTP plumbing ────────────────────────────────────────
_HEADERS = {
"x-rapidapi-key": config.RAPIDAPI_KEY,
"x-rapidapi-host": config.RAPIDAPI_HOST,
}
def _get(path: str, params: dict | None = None, timeout: int = 15) -> Any:
"""GET {BASE}{path}?{params} with TTL cache. Returns parsed JSON body."""
url = f"{config.RAPIDAPI_BASE}{path}"
cache_key = f"{path}|{sorted(params.items()) if params else ''}"
# Check cache
hit = _CACHE.get(cache_key)
if hit:
ts, data = hit
if time.time() - ts < _CACHE_TTL:
return data
resp = requests.get(url, headers=_HEADERS, params=params, timeout=timeout)
resp.raise_for_status()
data = resp.json()
# Cache the response
_CACHE[cache_key] = (time.time(), data)
return data
# ── TTL cache ────────────────────────────────────────────
_CACHE: dict[str, tuple[float, Any]] = {}
_CACHE_TTL = 300 # 5 minutes
def clear_cache() -> None:
"""Flush the in-memory response cache."""
_CACHE.clear()
# ── Connectivity check ──────────────────────────────────
def check_connection() -> dict:
"""Quick probe — hit a lightweight endpoint and measure latency."""
try:
t0 = time.time()
_get("/football-popular-leagues", timeout=8)
latency_ms = round((time.time() - t0) * 1000, 1)
return {"connected": True, "latency_ms": latency_ms, "backend": "RapidAPI"}
except Exception as e:
return {"connected": False, "latency_ms": None, "backend": "RapidAPI",
"error": str(e)}
# ── Search ───────────────────────────────────────────────
def search_all(query: str) -> Any:
"""Universal search across teams, players, leagues, matches."""
return _get("/football-all-search", {"search": query})
def search_teams(query: str) -> Any:
"""Search for teams by name."""
return _get("/football-teams-search", {"search": query})
def search_players(query: str) -> Any:
"""Search for players by name."""
return _get("/football-players-search", {"search": query})
def search_leagues(query: str) -> Any:
"""Search for leagues by name."""
return _get("/football-leagues-search", {"search": query})
def search_matches(query: str) -> Any:
"""Search for matches by team/event name."""
return _get("/football-matches-search", {"search": query})
# ── Leagues & countries ──────────────────────────────────
def get_popular_leagues() -> Any:
"""List popular leagues."""
return _get("/football-popular-leagues")
def get_all_leagues() -> Any:
"""List all available leagues."""
return _get("/football-get-all-leagues")
def get_league_detail(league_id: int) -> Any:
"""Get detailed info for a specific league."""
return _get("/football-get-league-detail", {"leagueid": league_id})
def get_all_countries() -> Any:
"""List all countries with leagues."""
return _get("/football-get-all-countries")
# ── Live scores ──────────────────────────────────────────
def get_live_matches() -> Any:
"""All currently live matches worldwide."""
return _get("/football-current-live")
# ── Fixtures ─────────────────────────────────────────────
def get_matches_by_date(date: str) -> Any:
"""
Matches on a given date.
date format: YYYYMMDD (e.g. '20260308')
"""
return _get("/football-get-matches-by-date", {"date": date})
def get_matches_by_date_and_league(date: str, league_id: int) -> Any:
"""Matches on a given date filtered by league."""
return _get("/football-get-matches-by-date-and-league",
{"date": date, "leagueid": league_id})
def get_league_matches(league_id: int) -> Any:
"""All matches for a league (current season)."""
return _get("/football-get-all-matches-by-league", {"leagueid": league_id})
# ── Teams ────────────────────────────────────────────────
def get_league_teams(league_id: int) -> Any:
"""All teams in a league."""
return _get("/football-get-list-all-team", {"leagueid": league_id})
def get_team_detail(team_id: int) -> Any:
"""Detailed info for a specific team."""
return _get("/football-league-team", {"teamid": team_id})
# ── Players / Squad ──────────────────────────────────────
def get_squad(team_id: int) -> Any:
"""Full squad roster for a team."""
return _get("/football-get-list-player", {"teamid": team_id})
def get_player_detail(player_id: int) -> Any:
"""Detailed player profile."""
return _get("/football-get-player-detail", {"playerid": player_id})
# ── Match detail & stats ────────────────────────────────
def get_match_detail(event_id: int) -> Any:
"""Full match overview (teams, score, status, timing)."""
return _get("/football-get-match-detail", {"eventid": event_id})
def get_match_score(event_id: int) -> Any:
"""Current/final score for a match."""
return _get("/football-get-match-score", {"eventid": event_id})
def get_match_status(event_id: int) -> Any:
"""Match status (scheduled, live, finished, etc.)."""
return _get("/football-get-match-status", {"eventid": event_id})
def get_match_stats(event_id: int) -> Any:
"""Full-match statistics (possession, shots, passes, etc.)."""
return _get("/football-get-match-all-stats", {"eventid": event_id})
def get_match_first_half_stats(event_id: int) -> Any:
"""First-half statistics."""
return _get("/football-get-match-firstHalf-stats", {"eventid": event_id})
def get_match_second_half_stats(event_id: int) -> Any:
"""Second-half statistics."""
return _get("/football-get-match-secondhalf-stats", {"eventid": event_id})
def get_match_highlights(event_id: int) -> Any:
"""Match highlights / key events."""
return _get("/football-get-match-highlights", {"eventid": event_id})
def get_match_location(event_id: int) -> Any:
"""Match venue info."""
return _get("/football-get-match-location", {"eventid": event_id})
def get_match_referee(event_id: int) -> Any:
"""Match referee info."""
return _get("/football-get-match-referee", {"eventid": event_id})
# ── Lineups ──────────────────────────────────────────────
def get_home_lineup(event_id: int) -> Any:
"""Home team lineup and formation."""
return _get("/football-get-hometeam-lineup", {"eventid": event_id})
def get_away_lineup(event_id: int) -> Any:
"""Away team lineup and formation."""
return _get("/football-get-awayteam-lineup", {"eventid": event_id})
def get_lineups(event_id: int) -> dict:
"""Both lineups combined into a single dict."""
return {
"home": get_home_lineup(event_id),
"away": get_away_lineup(event_id),
}
# ── Head to head ─────────────────────────────────────────
def get_head_to_head(event_id: int) -> Any:
"""Head-to-head history for the teams in a match."""
return _get("/football-get-head-to-head", {"eventid": event_id})
# ── Standings ────────────────────────────────────────────
def get_standings(league_id: int) -> Any:
"""Full league table (overall)."""
return _get("/football-get-standing-all", {"leagueid": league_id})
def get_home_standings(league_id: int) -> Any:
"""Home-only standings."""
return _get("/football-get-standing-home", {"leagueid": league_id})
def get_away_standings(league_id: int) -> Any:
"""Away-only standings."""
return _get("/football-get-standing-away", {"leagueid": league_id})
# ── Top players ──────────────────────────────────────────
def get_top_scorers(league_id: int) -> Any:
"""Top goal scorers in the league."""
return _get("/football-get-top-players-by-goals", {"leagueid": league_id})
def get_top_assists(league_id: int) -> Any:
"""Top assist providers in the league."""
return _get("/football-get-top-players-by-assists", {"leagueid": league_id})
def get_top_rated(league_id: int) -> Any:
"""Highest-rated players in the league."""
return _get("/football-get-top-players-by-rating", {"leagueid": league_id})
# ── Transfers ────────────────────────────────────────────
def get_league_transfers(league_id: int) -> Any:
"""Transfer activity for a league."""
return _get("/football-get-league-transfers", {"leagueid": league_id})
def get_team_transfers_in(team_id: int) -> Any:
"""Players signed by a team."""
return _get("/football-get-team-players-in-transfers", {"teamid": team_id})
def get_team_transfers_out(team_id: int) -> Any:
"""Players who left a team."""
return _get("/football-get-team-players-out-transfers", {"teamid": team_id})
def get_all_transfers(page: int = 1) -> Any:
"""All recent transfers (paginated)."""
return _get("/football-get-all-transfers", {"page": page})
def get_top_transfers(page: int = 1) -> Any:
"""Top transfers by value (paginated)."""
return _get("/football-get-top-transfers", {"page": page})
# ── News ─────────────────────────────────────────────────
def get_trending_news() -> Any:
"""Trending football news worldwide."""
return _get("/football-get-trendingnews")
def get_league_news(league_id: int, page: int = 1) -> Any:
"""News for a specific league."""
return _get("/football-get-league-news", {"leagueid": league_id, "page": page})
def get_team_news(team_id: int, page: int = 1) -> Any:
"""News for a specific team."""
return _get("/football-get-team-news", {"teamid": team_id, "page": page})
# ── Rounds ───────────────────────────────────────────────
def get_all_rounds(league_id: int) -> Any:
"""All rounds/matchdays for a league."""
return _get("/football-get-all-rounds", {"leagueid": league_id})
def get_round_detail(round_id: int) -> Any:
"""Detail for a specific round."""
return _get("/football-get-rounds-detail", {"roundid": round_id})
# ── Trophies ─────────────────────────────────────────────
def get_trophies_all_seasons(league_id: int) -> Any:
"""Trophy/title winners across all seasons."""
return _get("/football-get-trophies-all-seasons", {"leagueid": league_id})
def get_trophies_detail(league_id: int, season: str) -> Any:
"""Trophy detail for a specific season (e.g. '2023/2024')."""
return _get("/football-get-trophies-detail",
{"leagueid": league_id, "season": season})
# ── Odds ─────────────────────────────────────────────────
def get_event_odds(event_id: int, country_code: str = "US") -> Any:
"""Betting odds for a match."""
return _get("/football-event-odds",
{"eventid": event_id, "countrycode": country_code})

View File

@@ -1,9 +1,9 @@
"""
Nike MCP Server + Bootstrap Dashboard
======================================
Nike MCP Server + Dashboard
===========================
Single process on 0.0.0.0:8000
/ → Bootstrap status dashboard
/ → SvelteKit dashboard (dashboard/build)
/api/* → Dashboard JSON API
/mcp → FastMCP HTTP endpoint (streamable-HTTP)
@@ -28,7 +28,6 @@ import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from prometheus_client import (
CONTENT_TYPE_LATEST,
CollectorRegistry,
@@ -539,7 +538,7 @@ def get_match_result(team_name: str, match_date: str) -> str:
return "\n".join(lines)
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True), tags={"premium"})
def get_match_detail(event_id: int) -> str:
"""
Get deep match detail: stats, lineup, and timeline for a specific match.
@@ -651,7 +650,7 @@ def get_match_detail(event_id: int) -> str:
return "\n".join(lines)
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True), tags={"premium"})
def get_livescores() -> str:
"""
Get current live soccer scores worldwide.
@@ -731,9 +730,6 @@ def football_analyst() -> str:
_mcp_app = mcp.http_app(path="/")
# ── FastAPI dashboard app ─────────────────────────────────
_TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).parent / "templates")
)
@asynccontextmanager
@@ -781,11 +777,15 @@ _SVELTE_BUILD = Path(__file__).parent.parent / "dashboard" / "build"
@dashboard.get("/", response_class=HTMLResponse)
async def index(request: Request):
async def index():
svelte_index = _SVELTE_BUILD / "index.html"
if svelte_index.exists():
return FileResponse(str(svelte_index))
return _TEMPLATES.TemplateResponse(request, "dashboard.html")
return HTMLResponse(
"<h1>Nike</h1><p>Dashboard not built. "
"Run: <code>cd dashboard &amp;&amp; npm install &amp;&amp; npm run build</code></p>",
status_code=503,
)
@dashboard.get("/api/status")
@@ -797,19 +797,8 @@ async def api_status():
uptime_s = int((datetime.now(timezone.utc) - _SERVER_START).total_seconds())
uptime_str = f"{uptime_s // 3600}h {(uptime_s % 3600) // 60}m {uptime_s % 60}s"
is_premium = config.SPORTSDB_KEY not in ('3', '')
tool_count = len(await mcp.list_tools())
tools = [
{"name": "get_team_info", "description": "Team profile", "readonly": True},
{"name": "get_roster", "description": "Squad by position", "readonly": True},
{"name": "get_player_info", "description": "Player profile", "readonly": True},
{"name": "get_fixtures", "description": "Results & upcoming", "readonly": True},
{"name": "get_standings", "description": "League table", "readonly": True},
{"name": "get_match_result", "description": "Match on a date", "readonly": True},
{"name": "get_match_detail", "description": "Stats/lineup/timeline", "readonly": True,
"premium": True},
{"name": "get_livescores", "description": "Live scores", "readonly": True,
"premium": True},
]
db_status.setdefault("host", config.DB_HOST)
return JSONResponse({
"database": db_status,
@@ -820,7 +809,7 @@ async def api_status():
"endpoint": f"http://{config.SERVER_HOST}:{config.SERVER_PORT}/mcp",
"port": config.SERVER_PORT,
"uptime": uptime_str,
"tool_count": len(tools),
"tool_count": tool_count,
"premium": is_premium,
},
"data": {
@@ -829,10 +818,31 @@ async def api_status():
"followed": [{"team": t[0], "league": t[1]}
for t in config.FOLLOWED_TEAMS],
},
"tools": tools,
})
@dashboard.get("/api/tools")
async def api_tools():
"""Tool catalogue derived from the registered MCP tools (single source)."""
out = []
for t in await mcp.list_tools():
schema = t.parameters or {}
required = set(schema.get("required", []))
out.append({
"name": t.name,
"description": t.description or "",
"premium": "premium" in t.tags,
"params": [
{"name": name,
"type": spec.get("type", "string"),
"default": spec.get("default"),
"required": name in required}
for name, spec in schema.get("properties", {}).items()
],
})
return JSONResponse({"tools": out})
@dashboard.get("/api/logs")
async def api_logs(limit: int = 50):
return JSONResponse({"logs": list(_REQUEST_LOG)[:limit]})
@@ -850,41 +860,8 @@ async def api_cache_invalidate():
})
@dashboard.post("/api/sync")
async def api_sync():
"""Cache refresh — replaces the legacy API-Football DB sync."""
start = time.perf_counter()
db.invalidate_cache("%")
api.clear_cache()
duration = round(time.perf_counter() - start, 2)
return JSONResponse({
"ok": True,
"result": {"players": 0, "seasons": {}},
"duration_s": duration,
"note": "Cache cleared; fresh data will be fetched on next request.",
})
# ── Tool runner API ───────────────────────────────────────
_TOOLS: dict[str, Any] = {} # populated after tool definitions exist
def _register_tools() -> None:
_TOOLS.update({
"get_team_info": get_team_info,
"get_roster": get_roster,
"get_player_info": get_player_info,
"get_fixtures": get_fixtures,
"get_standings": get_standings,
"get_match_result": get_match_result,
"get_match_detail": get_match_detail,
"get_livescores": get_livescores,
})
_register_tools()
class _RunRequest(BaseModel):
tool: str
@@ -908,11 +885,11 @@ class _TelemetryReport(BaseModel):
@dashboard.post("/api/run")
async def api_run(body: _RunRequest):
fn = _TOOLS.get(body.tool)
if fn is None:
tool = await mcp.get_tool(body.tool)
if tool is None:
return JSONResponse({"ok": False, "error": f"Unknown tool: {body.tool!r}"}, status_code=400)
try:
result = fn(**body.args)
result = tool.fn(**body.args)
return JSONResponse({"ok": True, "result": result})
except Exception as exc:
logger.error("Tool run failed: tool=%s args=%s", body.tool, body.args, exc_info=True)

View File

@@ -1,250 +0,0 @@
"""
Data synchronisation logic.
Reusable by both scripts/ and the MCP sync_data tool.
"""
from __future__ import annotations
from nike import api_football, config
from nike.db import get_conn
def _upsert_league(cur, league_id: int, season: int) -> int:
cur.execute("""
INSERT INTO leagues (api_football_id, name, country, current_season, is_followed)
VALUES (%s, 'Major League Soccer', 'USA', %s, TRUE)
ON CONFLICT (api_football_id) DO UPDATE SET
current_season = EXCLUDED.current_season,
updated_at = NOW()
RETURNING id
""", (league_id, season))
return cur.fetchone()[0]
def _upsert_team(cur, t: dict, v: dict | None, *, followed: bool = False) -> int:
cur.execute("""
INSERT INTO teams (api_football_id, name, country, logo_url,
venue_name, venue_city, venue_capacity, is_followed)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (api_football_id) DO UPDATE SET
name = EXCLUDED.name,
logo_url = EXCLUDED.logo_url,
venue_name = EXCLUDED.venue_name,
is_followed = CASE WHEN EXCLUDED.is_followed THEN TRUE
ELSE teams.is_followed END,
updated_at = NOW()
RETURNING id
""", (
t['id'], t['name'], t.get('country'), t.get('logo'),
v.get('name') if v else None,
v.get('city') if v else None,
v.get('capacity') if v else None,
followed,
))
return cur.fetchone()[0]
def sync_team_data(team_search: str = config.TFC_SEARCH,
seasons: list[int] = config.SEASONS) -> dict:
"""
Find a team by name, then upsert league / team / squad / fixtures.
Returns a result dict with counts of records stored.
"""
results: dict = {
"team": None, "seasons": {}, "players": 0,
"squad": [], "all_fixtures": [],
"errors": [], "api_calls": 0,
}
# 1. Find team
teams = api_football.search_teams(team_search)
results["api_calls"] += 1
tfc = None
for item in teams:
t = item['team']
if 'Toronto' in t['name']:
tfc = item
break
if not tfc:
# fallback: fetch all teams in MLS and scan for Toronto
teams2 = api_football.get_teams_by_league(config.MLS_LEAGUE_ID)
results["api_calls"] += 1
for item in teams2:
if 'Toronto' in item['team']['name']:
tfc = item
break
if not tfc:
results["errors"].append("Toronto FC not found in API")
return results
tfc_team = tfc['team']
tfc_venue = tfc.get('venue', {})
results["team"] = tfc_team['name']
with get_conn() as conn:
cur = conn.cursor()
league_db_id = _upsert_league(cur, config.MLS_LEAGUE_ID, max(seasons))
tfc_db_id = _upsert_team(cur, tfc_team, tfc_venue, followed=True)
# 2. Squad
squad = api_football.get_squad(tfc_team['id'])
results["api_calls"] += 1
for p in squad:
cur.execute("""
INSERT INTO players (api_football_id, name, position,
shirt_number, current_team_id, photo_url)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (api_football_id) DO UPDATE SET
name = EXCLUDED.name,
position = EXCLUDED.position,
shirt_number = EXCLUDED.shirt_number,
current_team_id = EXCLUDED.current_team_id,
photo_url = EXCLUDED.photo_url,
updated_at = NOW()
""", (p['id'], p['name'], p.get('position'),
p.get('number'), tfc_db_id, p.get('photo')))
results["players"] = len(squad)
results["squad"] = squad
# 3. Fixtures per season
for season in seasons:
fixtures = []
try:
fixtures = api_football.get_fixtures(
tfc_team['id'], season, config.MLS_LEAGUE_ID)
results["api_calls"] += 1
except Exception as e:
results["errors"].append(f"Fixtures {season}: {e}")
results["seasons"][season] = 0
continue
count = 0
for f in fixtures:
fix = f['fixture']
teams = f['teams']
goals = f['goals']
lg = f['league']
home, away = teams['home'], teams['away']
for team_data in [home, away]:
cur.execute("""
INSERT INTO teams (api_football_id, name, country, logo_url)
VALUES (%s, %s, 'USA', %s)
ON CONFLICT (api_football_id) DO NOTHING
""", (team_data['id'], team_data['name'], team_data.get('logo')))
cur.execute("SELECT id FROM teams WHERE api_football_id = %s", (home['id'],))
home_db = cur.fetchone()[0]
cur.execute("SELECT id FROM teams WHERE api_football_id = %s", (away['id'],))
away_db = cur.fetchone()[0]
cur.execute("""
INSERT INTO fixtures
(api_football_id, league_id, season, round, match_date,
venue_name, venue_city, status, elapsed_minutes,
home_team_id, away_team_id, home_goals, away_goals)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (api_football_id) DO UPDATE SET
status = EXCLUDED.status,
home_goals = EXCLUDED.home_goals,
away_goals = EXCLUDED.away_goals,
elapsed_minutes = EXCLUDED.elapsed_minutes,
updated_at = NOW()
""", (
fix['id'], league_db_id, season, lg.get('round'), fix['date'],
fix['venue'].get('name') if fix.get('venue') else None,
fix['venue'].get('city') if fix.get('venue') else None,
fix['status']['short'], fix['status'].get('elapsed'),
home_db, away_db, goals.get('home'), goals.get('away'),
))
count += 1
hg = goals.get('home')
ag = goals.get('away')
score = f"{hg}-{ag}" if hg is not None and ag is not None else ""
results["all_fixtures"].append({
"date": fix["date"],
"home": home["name"],
"away": away["name"],
"status": fix["status"]["short"],
"score": score,
"venue": (fix.get("venue") or {}).get("name") or "TBD",
"round": lg.get("round", ""),
})
results["seasons"][season] = count
# 4. Followed entity
cur.execute("""
INSERT INTO followed_entities
(entity_type, entity_id, api_football_id, priority)
VALUES ('team', %s, %s, 'high')
ON CONFLICT DO NOTHING
""", (tfc_db_id, tfc_team['id']))
cur.close()
return results
def sync_standings(league_id: int, season: int) -> dict:
"""Sync current MLS standings into the DB."""
rows = api_football.get_standings(league_id, season)
inserted = 0
with get_conn() as conn:
cur = conn.cursor()
for entry in rows:
# Field names vary by API — try common variants
team_data = (entry.get('team') or
{'id': entry.get('teamid') or entry.get('team_id'),
'name': entry.get('teamname') or entry.get('name', ''),
'logo': entry.get('logo')})
# Nested stats may live under 'all', 'overall', or at top level
stats = entry.get('all') or entry.get('overall') or entry
team_api_id = team_data.get('id') or team_data.get('teamid')
if not team_api_id:
continue
# Ensure team exists
cur.execute("""
INSERT INTO teams (api_football_id, name, country, logo_url)
VALUES (%s, %s, 'USA', %s)
ON CONFLICT (api_football_id) DO NOTHING
""", (team_api_id, team_data.get('name', ''), team_data.get('logo')))
cur.execute("SELECT id FROM leagues WHERE api_football_id = %s", (league_id,))
league_row = cur.fetchone()
if not league_row:
continue
league_db_id = league_row[0]
cur.execute("SELECT id FROM teams WHERE api_football_id = %s", (team_api_id,))
team_row = cur.fetchone()
if not team_row:
continue
team_db_id = team_row[0]
played = (stats.get('played') or stats.get('gp') or
stats.get('games_played') or 0)
wins = (stats.get('win') or stats.get('wins') or stats.get('w') or 0)
draws = (stats.get('draw') or stats.get('draws') or stats.get('d') or 0)
losses = (stats.get('lose') or stats.get('losses') or stats.get('l') or 0)
goals = stats.get('goals') or {}
gf = goals.get('for') or stats.get('goals_for') or 0
ga = goals.get('against') or stats.get('goals_against') or 0
cur.execute("""
INSERT INTO standings
(league_id, season, team_id, rank, points, played,
wins, draws, losses, goals_for, goals_against,
goal_difference, form)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT DO NOTHING
""", (
league_db_id, season, team_db_id,
entry.get('rank') or entry.get('position'),
entry.get('points') or entry.get('pts'),
played, wins, draws, losses, gf, ga,
entry.get('goalsDiff') or entry.get('goal_difference') or (gf - ga),
entry.get('form', ''),
))
inserted += 1
cur.close()
return {"standings_inserted": inserted}

View File

@@ -1,471 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nike — Football Data Platform</title>
<!-- Bootstrap 5 -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
crossorigin="anonymous" />
<!-- Bootstrap Icons -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
crossorigin="anonymous" />
<style>
body { background-color: #0d111a; }
/* Top nav */
.navbar-brand .badge { font-size: .6rem; vertical-align: middle; }
/* Status cards */
.status-card { border-left: 4px solid transparent; transition: border-color .3s; }
.status-card.ok { border-left-color: #198754; }
.status-card.fail { border-left-color: #dc3545; }
.status-card.unknown { border-left-color: #6c757d; }
.dot {
display:inline-block; width:10px; height:10px;
border-radius:50%; margin-right: 6px;
}
.dot.green { background:#198754; box-shadow: 0 0 6px #198754; animation: pulse 2s infinite; }
.dot.red { background:#dc3545; }
.dot.grey { background:#6c757d; }
@keyframes pulse {
0%,100% { opacity:1; }
50% { opacity:.4; }
}
/* Uptime badge */
.uptime { font-size:.75rem; color:#adb5bd; }
/* Table tweaks */
.table-sm td, .table-sm th { font-size: .83rem; }
.log-table { max-height: 340px; overflow-y: auto; }
/* Quota bar */
.quota-label { font-size: .75rem; color: #adb5bd; }
/* Tool badge */
.tool-readonly { background: #0d6efd22; color: #6ea8fe; }
.tool-write { background: #dc354522; color: #ea868f; }
/* Refreshing spinner */
#refreshSpinner { display: none; }
/* Toast */
#syncToast { min-width: 300px; }
</style>
</head>
<body>
<!-- ── Navbar ─────────────────────────────────────────── -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
<div class="container-fluid">
<a class="navbar-brand fw-bold" href="/">
<i class="bi bi-trophy-fill text-warning me-2"></i>Nike
<span class="badge bg-secondary ms-2">Football Data Platform</span>
</a>
<div class="ms-auto d-flex align-items-center gap-3">
<span class="uptime" id="uptimeDisplay"></span>
<span id="refreshSpinner" class="spinner-border spinner-border-sm text-secondary"></span>
<button class="btn btn-sm btn-outline-secondary" onclick="refreshStatus()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</nav>
<!-- ── Toast ─────────────────────────────────────────── -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:11">
<div id="syncToast" class="toast align-items-center" role="alert">
<div class="d-flex">
<div class="toast-body" id="syncToastBody">Sync started…</div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<div class="container-fluid py-4 px-4">
<!-- ── Row 1: Status Cards ─────────────────────────── -->
<div class="row g-3 mb-4">
<!-- Database -->
<div class="col-md-4">
<div class="card status-card unknown h-100" id="cardDb">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="card-subtitle text-muted mb-1 text-uppercase" style="font-size:.7rem;letter-spacing:.08em">
Database
</h6>
<h5 class="card-title mb-0">
<span class="dot grey" id="dotDb"></span>
<span id="dbStatus">Checking…</span>
</h5>
</div>
<i class="bi bi-database-fill fs-2 text-secondary opacity-25"></i>
</div>
<hr class="my-2" />
<div class="row row-cols-2 g-0 small text-muted">
<div>Host</div> <div id="dbHost" class="text-end text-light"></div>
<div>Latency</div> <div id="dbLatency" class="text-end text-light"></div>
<div>Version</div> <div id="dbVersion" class="text-end text-light" style="font-size:.72rem"></div>
<div>Status</div> <div id="dbMsg" class="text-end text-light"></div>
</div>
</div>
</div>
</div>
<!-- API-Football -->
<div class="col-md-4">
<div class="card status-card unknown h-100" id="cardApi">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="card-subtitle text-muted mb-1 text-uppercase" style="font-size:.7rem;letter-spacing:.08em">
API-Football
</h6>
<h5 class="card-title mb-0">
<span class="dot grey" id="dotApi"></span>
<span id="apiStatus">Checking…</span>
</h5>
</div>
<i class="bi bi-globe2 fs-2 text-secondary opacity-25"></i>
</div>
<hr class="my-2" />
<div class="row row-cols-2 g-0 small text-muted mb-2">
<div>Latency</div> <div id="apiLatency" class="text-end text-light"></div>
<div>Quota used</div> <div id="apiQuota" class="text-end text-light"></div>
</div>
<div class="quota-label mb-1">Daily quota</div>
<div class="progress" style="height:6px;" title="Remaining API calls">
<div id="quotaBar" class="progress-bar bg-success" style="width:100%"></div>
</div>
</div>
</div>
</div>
<!-- MCP Server -->
<div class="col-md-4">
<div class="card status-card ok h-100" id="cardMcp">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="card-subtitle text-muted mb-1 text-uppercase" style="font-size:.7rem;letter-spacing:.08em">
MCP Server
</h6>
<h5 class="card-title mb-0">
<span class="dot green"></span>Running
</h5>
</div>
<i class="bi bi-cpu-fill fs-2 text-secondary opacity-25"></i>
</div>
<hr class="my-2" />
<div class="row row-cols-2 g-0 small text-muted">
<div>Transport</div> <div id="mcpTransport" class="text-end text-light"></div>
<div>Endpoint</div> <div id="mcpEndpoint" class="text-end text-light" style="font-size:.7rem;word-break:break-all"></div>
<div>Uptime</div> <div id="mcpUptime" class="text-end text-light"></div>
<div>Tools</div> <div id="mcpTools" class="text-end text-light"></div>
</div>
</div>
</div>
</div>
</div><!-- /Row 1 -->
<!-- ── Row 2: Data Summary + Sync ─────────────────── -->
<div class="row g-3 mb-4">
<!-- Data summary -->
<div class="col-md-8">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-table me-2 text-info"></i>Database Contents</span>
<span class="badge bg-secondary" id="lastSyncBadge">Last sync: —</span>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover table-striped mb-0 text-nowrap">
<thead class="table-dark">
<tr>
<th class="ps-3">Table</th>
<th class="text-end pe-3">Rows</th>
<th class="text-end pe-3">Status</th>
</tr>
</thead>
<tbody id="tableCountsBody">
<tr><td colspan="3" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Sync control -->
<div class="col-md-4">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-arrow-repeat me-2 text-warning"></i>Data Sync
</div>
<div class="card-body d-flex flex-column justify-content-between">
<div>
<p class="text-muted small mb-3">
Triggers a fresh pull from <strong>API-Football</strong> for Toronto FC —
both 2025 and 2026 seasons. Uses approx. 4 of your 100 daily quota calls.
</p>
<ul class="small text-muted ps-3 mb-3">
<li>Squad (players/squads)</li>
<li>Fixtures 2025 (fixtures)</li>
<li>Fixtures 2026 (fixtures)</li>
<li>Team &amp; league upsert</li>
</ul>
</div>
<div>
<button id="syncBtn" class="btn btn-warning w-100 fw-bold" onclick="triggerSync()">
<i class="bi bi-arrow-clockwise me-2"></i>Refresh Cache
</button>
<div id="syncResult" class="mt-2 small"></div>
</div>
</div>
</div>
</div>
</div><!-- /Row 2 -->
<!-- ── Row 3: Tools + Request Log ──────────────────── -->
<div class="row g-3">
<!-- MCP Tools -->
<div class="col-md-5">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-tools me-2 text-primary"></i>MCP Tools
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr>
<th class="ps-3">Tool</th>
<th>Description</th>
<th class="text-center">Type</th>
</tr>
</thead>
<tbody id="toolsBody">
<tr><td colspan="3" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
<div class="card-footer text-muted small">
MCP endpoint: <code id="mcpEndpointFooter"></code>
</div>
</div>
</div>
<!-- Request Log -->
<div class="col-md-7">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-journal-text me-2 text-success"></i>Request Log</span>
<span class="badge bg-dark border border-secondary" id="logCount">0 entries</span>
</div>
<div class="card-body p-0 log-table">
<table class="table table-sm table-hover mb-0 text-nowrap">
<thead class="table-dark sticky-top">
<tr>
<th class="ps-3" style="width:130px">Time</th>
<th>Tool</th>
<th>Args</th>
<th class="text-end pe-3">ms</th>
</tr>
</thead>
<tbody id="logBody">
<tr><td colspan="4" class="text-center text-muted py-3">No requests yet</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div><!-- /Row 3 -->
</div><!-- /container -->
<!-- ── Scripts ────────────────────────────────────────── -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
<script>
const toast = new bootstrap.Toast(document.getElementById('syncToast'));
function setDot(id, state) {
const d = document.getElementById(id);
d.className = `dot ${state}`;
}
function setCard(id, state) {
const c = document.getElementById(id);
c.className = `card status-card h-100 ${state}`;
}
function setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val ?? '—';
}
async function refreshStatus() {
document.getElementById('refreshSpinner').style.display = 'inline-block';
try {
const [status, logs] = await Promise.all([
fetch('/api/status').then(r => r.json()),
fetch('/api/logs?limit=50').then(r => r.json()),
]);
renderStatus(status);
renderLogs(logs.logs || []);
} catch (e) {
console.error('Status fetch failed:', e);
} finally {
document.getElementById('refreshSpinner').style.display = 'none';
}
}
function renderStatus(s) {
// ── Database ──
const db = s.database;
if (db.connected) {
setDot('dotDb', 'green');
setCard('cardDb', 'ok');
setText('dbStatus', 'Connected');
setText('dbHost', db.host || 'portia.incus');
setText('dbLatency', db.latency_ms != null ? db.latency_ms + ' ms' : '—');
setText('dbVersion', db.version || '—');
setText('dbMsg', 'OK');
} else {
setDot('dotDb', 'red');
setCard('cardDb', 'fail');
setText('dbStatus', 'Offline');
setText('dbMsg', db.error || 'Connection failed');
setText('dbHost', '—');
setText('dbLatency', '—');
setText('dbVersion', '—');
}
// ── API ──
const apiv = s.api;
if (apiv.connected) {
setDot('dotApi', 'green');
setCard('cardApi', 'ok');
setText('apiStatus', 'Reachable');
setText('apiLatency', apiv.latency_ms != null ? apiv.latency_ms + ' ms' : '—');
const used = (apiv.quota_limit || 100) - (apiv.quota_remaining || 0);
const pct = Math.round((apiv.quota_remaining || 0) / (apiv.quota_limit || 100) * 100);
setText('apiQuota', `${used} / ${apiv.quota_limit || 100}`);
const bar = document.getElementById('quotaBar');
bar.style.width = pct + '%';
bar.className = `progress-bar ${pct > 40 ? 'bg-success' : pct > 15 ? 'bg-warning' : 'bg-danger'}`;
} else {
setDot('dotApi', 'red');
setCard('cardApi', 'fail');
setText('apiStatus', 'Unreachable');
setText('apiLatency', '—');
setText('apiQuota', '—');
}
// ── MCP ──
const mcp = s.mcp;
setText('mcpTransport', mcp.transport);
setText('mcpEndpoint', mcp.endpoint);
setText('mcpUptime', mcp.uptime);
setText('mcpTools', mcp.tool_count + ' tools');
setText('uptimeDisplay', 'Uptime: ' + mcp.uptime);
document.getElementById('mcpEndpointFooter').textContent = mcp.endpoint;
// ── Data counts ──
const counts = s.data.table_counts;
const lastSync = s.data.last_sync;
setText('lastSyncBadge', lastSync ? 'Last sync: ' + lastSync.slice(0, 19).replace('T', ' ') : 'Not synced');
const rows = Object.entries(counts).map(([tbl, cnt]) => {
const ok = cnt > 0;
return `<tr>
<td class="ps-3">${tbl}</td>
<td class="text-end pe-3 ${ok ? 'text-light' : 'text-muted'}">${cnt < 0 ? 'error' : cnt.toLocaleString()}</td>
<td class="text-end pe-3">${cnt > 0 ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<span class="text-muted">—</span>'}</td>
</tr>`;
});
document.getElementById('tableCountsBody').innerHTML = rows.join('') || '<tr><td colspan="3" class="text-center text-muted">No data</td></tr>';
// ── Tools ──
const tools = s.tools || [];
const toolRows = tools.map(t => `
<tr>
<td class="ps-3"><code>${t.name}</code></td>
<td class="text-muted small">${t.description}</td>
<td class="text-center">
${t.readonly
? '<span class="badge tool-readonly">read-only</span>'
: '<span class="badge tool-write">write</span>'}
</td>
</tr>
`);
document.getElementById('toolsBody').innerHTML = toolRows.join('') || '<tr><td colspan="3" class="text-center text-muted">No tools</td></tr>';
}
function renderLogs(logs) {
document.getElementById('logCount').textContent = logs.length + ' entries';
if (!logs.length) {
document.getElementById('logBody').innerHTML =
'<tr><td colspan="4" class="text-center text-muted py-3">No requests yet</td></tr>';
return;
}
const rows = logs.map(l => {
const args = Object.entries(l.args || {})
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(', ');
const ts = l.timestamp.slice(11, 19);
return `<tr>
<td class="ps-3 text-muted">${ts}</td>
<td><code>${l.tool}</code></td>
<td class="text-muted small">${args || '—'}</td>
<td class="text-end pe-3 ${l.duration_ms > 500 ? 'text-warning' : 'text-muted'}">${l.duration_ms}</td>
</tr>`;
});
document.getElementById('logBody').innerHTML = rows.join('');
}
async function triggerSync() {
const btn = document.getElementById('syncBtn');
const result = document.getElementById('syncResult');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Refreshing…';
result.textContent = '';
document.getElementById('syncToastBody').textContent = 'Clearing cache…';
toast.show();
try {
const resp = await fetch('/api/sync', { method: 'POST' });
const data = await resp.json();
if (data.ok) {
result.innerHTML = `<span class="text-success">✅ Cache cleared in ${data.duration_s}s</span>`;
document.getElementById('syncToastBody').textContent = `Cache cleared in ${data.duration_s}s`;
await refreshStatus();
} else {
result.innerHTML = `<span class="text-danger">❌ ${data.error}</span>`;
document.getElementById('syncToastBody').textContent = 'Refresh failed: ' + data.error;
}
} catch (e) {
result.innerHTML = `<span class="text-danger">❌ Network error</span>`;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-2"></i>Refresh Cache';
}
}
// Initial load + auto-refresh every 30s
refreshStatus();
setInterval(refreshStatus, 30_000);
setInterval(() => fetch('/api/logs?limit=50').then(r=>r.json()).then(d=>renderLogs(d.logs||[])), 5_000);
</script>
</body>
</html>

View File

@@ -11,11 +11,10 @@ requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"fastmcp>=2.0",
"fastmcp>=3",
"psycopg2-binary>=2.9",
"python-dotenv>=1.0",
"requests>=2.32",
"jinja2>=3.1",
"wsproto>=1.2",
"prometheus-client>=0.20",
]

View File

@@ -1,20 +1,19 @@
#!/usr/bin/env python3
"""Apply Nike schema to Portia PostgreSQL."""
"""Apply the Nike cache schema (schema.sql) to PostgreSQL."""
import os
import sys
from dotenv import load_dotenv
import psycopg2
load_dotenv('/home/robert/gitea/nike/.env')
from nike import config
try:
conn = psycopg2.connect(
host=os.getenv('DB_HOST'),
port=int(os.getenv('DB_PORT', 5432)),
user=os.getenv('DB_USER'),
password=os.getenv('DB_PASSWORD'),
dbname='nike',
host=config.DB_HOST,
port=config.DB_PORT,
user=config.DB_USER,
password=config.DB_PASSWORD,
dbname=config.DB_NAME,
)
except Exception as e:
print(f"❌ Cannot connect to DB: {e}")

View File

@@ -1,327 +0,0 @@
#!/usr/bin/env python3
"""
API Discovery Script — walk the RapidAPI top-down, save raw responses.
Workflow:
1. Search for leagues (MLS, Premier League) → get league IDs
2. Search for teams (Toronto FC, Arsenal) → get team IDs
3. Get league matches → find recent past + upcoming event IDs
4. Get match detail, score, stats, location for a finished match
5. Get match highlights (goals, cards, events)
6. Get lineups for a finished match
7. Get squad roster + sample player detail
8. Get standings
Saves every response to docs/api_samples/{step}_{endpoint}.json
Cost: ~18 API calls
"""
import sys
import json
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nike import rapidapi as rapi
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "docs" / "api_samples"
SAMPLES_DIR.mkdir(parents=True, exist_ok=True)
def save(name: str, data) -> None:
"""Save raw API response to a JSON file."""
path = SAMPLES_DIR / f"{name}.json"
with open(path, "w") as f:
json.dump(data, f, indent=2, default=str)
size_kb = path.stat().st_size / 1024
print(f" ✓ Saved {path.name} ({size_kb:.1f} KB)")
def extract_id(data, label="item") -> int | None:
"""Try to pull an ID from the first search result."""
resp = data.get("response") if isinstance(data, dict) else None
# Handle nested response structures
items = []
if isinstance(resp, dict):
for key in ("suggestions", "teams", "leagues", "players", "matches"):
if key in resp and isinstance(resp[key], list) and resp[key]:
items = resp[key]
break
if not items:
items = [resp]
elif isinstance(resp, list):
items = resp
else:
# Try alternate envelope keys
for key in ("data", "result"):
val = data.get(key)
if isinstance(val, list) and val:
items = val
break
for item in items:
if isinstance(item, dict):
eid = item.get("id") or item.get("primaryId")
if eid:
name = item.get("name") or item.get("title") or "?"
print(f"{label}: {name} (ID: {eid})")
return int(eid)
print(f" ⚠ Could not extract ID from {label} response")
# Dump top-level keys for debugging
if isinstance(data, dict):
print(f" Top-level keys: {list(data.keys())}")
if isinstance(resp, dict):
print(f" response keys: {list(resp.keys())}")
return None
def find_event_ids(data, limit=2):
"""Extract event IDs from a matches/fixtures response."""
resp = data.get("response") if isinstance(data, dict) else None
events = {"past": [], "upcoming": []}
items = []
if isinstance(resp, dict):
for key in ("matches", "events", "fixtures", "allMatches"):
if key in resp and isinstance(resp[key], list):
items = resp[key]
break
if not items:
# Flatten if resp itself contains sub-lists
for key, val in resp.items():
if isinstance(val, list) and val and isinstance(val[0], dict):
items = val
break
elif isinstance(resp, list):
items = resp
else:
for key in ("data", "result"):
val = data.get(key)
if isinstance(val, list):
items = val
break
for item in items:
if not isinstance(item, dict):
continue
eid = item.get("id") or item.get("eventId") or item.get("primaryId")
if not eid:
continue
# Detect finished vs upcoming
status = item.get("status", {})
if isinstance(status, dict):
finished = (status.get("finished", False) or
status.get("short") == "FT" or
status.get("type") == "finished")
elif isinstance(status, str):
finished = status.lower() in ("ft", "aet", "pen", "finished")
else:
finished = False
bucket = "past" if finished else "upcoming"
events[bucket].append(int(eid))
return {k: v[:limit] for k, v in events.items()}
def main():
print("=" * 60)
print(" Nike API Discovery — Mapping Response Structures")
print("=" * 60)
# ══════════════════════════════════════════════════════
# STEP 1: League discovery
# ══════════════════════════════════════════════════════
print("\n[1/8] Searching for leagues...")
mls = rapi.search_leagues("MLS")
save("01_search_leagues_mls", mls)
mls_id = extract_id(mls, "MLS")
epl = rapi.search_leagues("Premier League")
save("01_search_leagues_epl", epl)
epl_id = extract_id(epl, "Premier League")
popular = rapi.get_popular_leagues()
save("01_popular_leagues", popular)
# ══════════════════════════════════════════════════════
# STEP 2: Team discovery
# ══════════════════════════════════════════════════════
print("\n[2/8] Searching for teams...")
tfc = rapi.search_teams("Toronto FC")
save("02_search_teams_tfc", tfc)
tfc_id = extract_id(tfc, "Toronto FC")
ars = rapi.search_teams("Arsenal")
save("02_search_teams_arsenal", ars)
ars_id = extract_id(ars, "Arsenal")
# ══════════════════════════════════════════════════════
# STEP 3: League matches (fixtures)
# ══════════════════════════════════════════════════════
print("\n[3/8] Fetching league matches...")
mls_events = {"past": [], "upcoming": []}
if mls_id:
mls_matches = rapi.get_league_matches(mls_id)
save("03_league_matches_mls", mls_matches)
mls_events = find_event_ids(mls_matches)
print(f" → Events found: {len(mls_events['past'])} past, "
f"{len(mls_events['upcoming'])} upcoming")
if not mls_events["past"] and not mls_events["upcoming"]:
# Dump structure hints to help debug
resp = mls_matches.get("response")
if isinstance(resp, dict):
print(f" response keys: {list(resp.keys())}")
for k, v in resp.items():
if isinstance(v, list) and v:
print(f" response.{k}[0] keys: "
f"{list(v[0].keys()) if isinstance(v[0], dict) else type(v[0])}")
elif isinstance(resp, list) and resp:
print(f" response[0] keys: "
f"{list(resp[0].keys()) if isinstance(resp[0], dict) else type(resp[0])}")
else:
print(" ⚠ No MLS ID, skipping")
# ══════════════════════════════════════════════════════
# STEP 4: Match detail (finished match)
# ══════════════════════════════════════════════════════
print("\n[4/8] Fetching match detail...")
event_id = None
if mls_events["past"]:
event_id = mls_events["past"][0]
elif mls_events["upcoming"]:
event_id = mls_events["upcoming"][0]
if event_id:
print(f" Using event ID: {event_id}")
detail = rapi.get_match_detail(event_id)
save("04_match_detail", detail)
score = rapi.get_match_score(event_id)
save("04_match_score", score)
status = rapi.get_match_status(event_id)
save("04_match_status", status)
location = rapi.get_match_location(event_id)
save("04_match_location", location)
else:
print(" ⚠ No event ID found — skipping match detail")
# ══════════════════════════════════════════════════════
# STEP 5: Match stats + highlights (goals, cards, events)
# ══════════════════════════════════════════════════════
print("\n[5/8] Fetching match stats & highlights...")
if event_id:
stats = rapi.get_match_stats(event_id)
save("05_match_stats", stats)
highlights = rapi.get_match_highlights(event_id)
save("05_match_highlights", highlights)
referee = rapi.get_match_referee(event_id)
save("05_match_referee", referee)
else:
print(" ⚠ Skipping (no event ID)")
# ══════════════════════════════════════════════════════
# STEP 6: Lineups
# ══════════════════════════════════════════════════════
print("\n[6/8] Fetching lineups...")
if event_id:
home_lineup = rapi.get_home_lineup(event_id)
save("06_lineup_home", home_lineup)
away_lineup = rapi.get_away_lineup(event_id)
save("06_lineup_away", away_lineup)
else:
print(" ⚠ Skipping (no event ID)")
# ══════════════════════════════════════════════════════
# STEP 7: Squad & player detail
# ══════════════════════════════════════════════════════
print("\n[7/8] Fetching squad & player detail...")
player_id = None
if tfc_id:
squad = rapi.get_squad(tfc_id)
save("07_squad_tfc", squad)
# Extract a player ID from the squad response
resp = squad.get("response") if isinstance(squad, dict) else None
if isinstance(resp, list) and resp:
for p in resp:
if isinstance(p, dict):
pid = p.get("id") or p.get("primaryId")
if pid:
player_id = int(pid)
print(f" → Sample player: {p.get('name', '?')} (ID: {player_id})")
break
elif isinstance(resp, dict):
# May be nested under squad/players/roster
for key in ("squad", "players", "roster", "members"):
if key in resp and isinstance(resp[key], list):
for p in resp[key]:
if isinstance(p, dict):
pid = p.get("id") or p.get("primaryId")
if pid:
player_id = int(pid)
print(f" → Sample player: {p.get('name', '?')} "
f"(ID: {player_id})")
break
break
if not player_id:
print(f" response keys: {list(resp.keys())}")
if player_id:
player = rapi.get_player_detail(player_id)
save("07_player_detail", player)
else:
print(" ⚠ Could not find a player ID in squad response")
else:
print(" ⚠ No TFC ID, skipping")
# ══════════════════════════════════════════════════════
# STEP 8: Standings
# ══════════════════════════════════════════════════════
print("\n[8/8] Fetching standings...")
if mls_id:
standings = rapi.get_standings(mls_id)
save("08_standings_mls", standings)
# ══════════════════════════════════════════════════════
# Summary
# ══════════════════════════════════════════════════════
print("\n" + "=" * 60)
files = sorted(SAMPLES_DIR.glob("*.json"))
print(f" Saved {len(files)} response samples to docs/api_samples/")
for f in files:
size_kb = f.stat().st_size / 1024
print(f" {f.name:.<50} {size_kb:.1f} KB")
print("=" * 60)
print("\n Discovered IDs:")
print(f" MLS league ID : {mls_id}")
print(f" EPL league ID : {epl_id}")
print(f" Toronto FC team ID : {tfc_id}")
print(f" Arsenal team ID : {ars_id}")
if event_id:
print(f" Sample event ID : {event_id}")
if player_id:
print(f" Sample player ID : {player_id}")
print()
if __name__ == "__main__":
main()

View File

@@ -1,103 +0,0 @@
#!/usr/bin/env python3
"""
Pull Toronto FC squad and fixtures and store in Portia DB.
Delegates all API calls and DB writes to nike.sync.sync_team_data()
so this script stays thin and the real logic lives in one place.
"""
import sys
from pathlib import Path
# Make sure the project root is on the path when run directly
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from datetime import date
from nike import config, db
from nike.sync import sync_team_data
def main() -> None:
if not config.API_FOOTBALL_KEY:
print("❌ API_FOOTBALL_KEY not set in .env")
sys.exit(1)
print("=" * 60)
print(" NIKE — Toronto FC Data Pull")
print(f" API: {config.API_FOOTBALL_BASE}")
print("=" * 60)
db.create_pool()
try:
results = sync_team_data(
team_search=config.TFC_SEARCH,
seasons=config.SEASONS,
)
finally:
db.close_pool()
# ── Summary ──────────────────────────────────────────
if results["errors"]:
print("\n⚠️ Errors encountered:")
for e in results["errors"]:
print(f"{e}")
print(f"\n✅ Team: {results.get('team', 'unknown')}")
print(f" Players: {results['players']}")
print(f" API calls: {results['api_calls']}")
for season, count in results.get("seasons", {}).items():
print(f" Fixtures {season}: {count}")
if results.get("squad"):
_print_squad(results["squad"])
if results.get("all_fixtures"):
_print_fixtures(results["all_fixtures"])
def _print_squad(squad: list[dict]) -> None:
print("\n" + "=" * 60)
print(" SQUAD")
print("=" * 60)
pos_order = ["Goalkeeper", "Defender", "Midfielder", "Attacker"]
by_pos: dict = {}
for p in squad:
by_pos.setdefault(p.get("position") or "Unknown", []).append(p)
for pos in pos_order + [k for k in by_pos if k not in pos_order]:
if pos not in by_pos:
continue
print(f"\n {pos.upper()}S:")
for p in sorted(by_pos[pos],
key=lambda x: x["number"] if isinstance(x.get("number"), int) else 99):
print(f" #{str(p.get('number', '-')).rjust(2)} {p['name']}")
def _print_fixtures(fixtures: list[dict]) -> None:
print("\n" + "=" * 60)
print(" FIXTURES")
print("=" * 60)
today = date.today().isoformat()
today_f = [f for f in fixtures if f["date"][:10] == today]
completed = [f for f in fixtures if f["status"] in ("FT", "AET", "PEN")]
upcoming = [f for f in fixtures if f["status"] in ("NS", "TBD", "PST")]
if today_f:
print("\n 🔴 TODAY:")
for f in today_f:
score = f" {f['score']}" if f.get("score") else ""
print(f" {f['home']}{score} {f['away']} @ {f['venue']} [{f['status']}]")
if completed:
print("\n RECENT RESULTS (last 5):")
for f in completed[-5:]:
print(f" {f['date'][:10]} {f['home']} {f['score']} {f['away']}")
if upcoming:
print("\n UPCOMING (next 5):")
for f in sorted(upcoming, key=lambda x: x["date"])[:5]:
print(f" {f['date'][:10]} {f['home']} vs {f['away']} [{f.get('round', '')}]")
if __name__ == "__main__":
main()

View File

@@ -1,75 +0,0 @@
#!/usr/bin/env python3
"""
Test API connectivity, find Toronto FC, and list popular leagues
so we can confirm the correct MLS league ID for this API.
Uses 2 API quota calls.
"""
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nike import config, api_football
def main() -> None:
if not config.API_FOOTBALL_KEY:
print("❌ API_FOOTBALL_KEY not set in .env")
sys.exit(1)
print(f"API: {config.API_FOOTBALL_BASE}\n")
# ── 1. Popular leagues (connectivity probe + league ID discovery) ──
print("── Popular Leagues ──────────────────────────")
try:
t0 = time.time()
data = api_football._get("football-popular-leagues", timeout=8)
latency_ms = round((time.time() - t0) * 1000, 1)
except Exception as e:
print(f"❌ Not connected: {e}")
sys.exit(1)
print(f"✅ Connected latency={latency_ms}ms "
f"quota_remaining={api_football.last_quota_remaining()}")
leagues = (data.get('response', {}).get('popular') or
data.get('response', {}).get('leagues') or [])
if not isinstance(leagues, list):
print(f" Raw response structure:\n{json.dumps(data, indent=2)[:1000]}")
else:
print(f" {len(leagues)} league(s) returned:")
for lg in leagues:
lg_id = lg.get('id', '?')
lg_name = lg.get('name', str(lg))
ccode = lg.get('ccode', '')
print(f" [{str(lg_id):>5}] {lg_name} ({ccode})")
print(f"\n ↳ Current config.MLS_LEAGUE_ID = {config.MLS_LEAGUE_ID}")
print(" Update nike/config.py if the ID above doesn't match MLS.\n")
# ── 2. Search for Toronto FC ───────────────────────────────────
print("── Search: Toronto ──────────────────────────")
raw_search = api_football._get("football-teams-search", {"search": "Toronto"})
print(f" quota_remaining={api_football.last_quota_remaining()}")
# Try all common envelope keys; fall back to full raw dump
raw_list = (raw_search.get('response', {}).get('suggestions') or
raw_search.get('response', {}).get('teams') or
raw_search.get('data') or raw_search.get('result'))
if not isinstance(raw_list, list) or not raw_list:
print(f" 0 results — raw response:\n{json.dumps(raw_search, indent=2)[:1500]}")
else:
teams = [api_football._normalise_team_item(t)
for t in raw_list if t.get('type', 'team') == 'team']
print(f" {len(teams)} team result(s):")
for item in teams:
t = item['team']
v = item.get('venue') or {}
print(f" [{str(t.get('id')):>6}] {t.get('name')} ({t.get('country')})")
if v.get('name'):
print(f" Venue: {v.get('name')}, {v.get('city')} cap={v.get('capacity')}")
if __name__ == "__main__":
main()

View File

@@ -1,36 +0,0 @@
#!/usr/bin/env python3
"""Test connection to Portia PostgreSQL."""
import os
import sys
import time
from dotenv import load_dotenv
import psycopg2
load_dotenv('/home/robert/gitea/nike/.env')
try:
t0 = time.time()
conn = psycopg2.connect(
host=os.getenv('DB_HOST'),
port=int(os.getenv('DB_PORT', 5432)),
user=os.getenv('DB_USER'),
password=os.getenv('DB_PASSWORD'),
dbname='nike',
connect_timeout=5,
)
latency_ms = round((time.time() - t0) * 1000, 1)
cur = conn.cursor()
cur.execute('SELECT version();')
version = cur.fetchone()[0]
cur.execute("SELECT current_database(), current_user;")
db, user = cur.fetchone()
cur.close()
conn.close()
print(f"✅ Connected in {latency_ms}ms")
print(f" Database : {db}")
print(f" User : {user}")
print(f" Version : {version.split(',')[0]}")
except Exception as e:
print(f"❌ Connection failed: {e}")
sys.exit(1)

View File

@@ -1,107 +0,0 @@
#!/usr/bin/env python3
"""
Smoke test for the RapidAPI (free-api-live-football-data) backend.
Verifies connectivity, finds MLS, fetches standings and TFC squad.
Run: python scripts/test_rapidapi.py
"""
import sys
import json
from pathlib import Path
# Ensure project root is on the path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nike import config
from nike import rapidapi as rapi
def _pp(label: str, data) -> None:
"""Pretty-print a section."""
print(f"\n{'='*60}")
print(f" {label}")
print(f"{'='*60}")
print(json.dumps(data, indent=2, default=str)[:3000]) # trim for readability
def main():
if not config.RAPIDAPI_KEY:
print("ERROR: RAPIDAPI_KEY is not set in .env")
sys.exit(1)
print(f"RapidAPI key: {config.RAPIDAPI_KEY[:8]}...{config.RAPIDAPI_KEY[-4:]}")
print(f"Base URL: {config.RAPIDAPI_BASE}")
# 1. Connectivity
print("\n1) Checking connectivity...")
status = rapi.check_connection()
print(f" Connected: {status['connected']}")
if not status["connected"]:
print(f" Error: {status.get('error')}")
sys.exit(1)
print(f" Latency: {status['latency_ms']} ms")
# 2. Search for MLS
print("\n2) Searching for 'MLS'...")
mls_data = rapi.search_leagues("MLS")
_pp("MLS search results", mls_data)
# Try to extract league ID
mls_id = None
resp = mls_data.get("response") if isinstance(mls_data, dict) else None
if isinstance(resp, list):
for item in resp:
if isinstance(item, dict):
mls_id = item.get("id") or item.get("primaryId")
if mls_id:
mls_id = int(mls_id)
break
print(f"\n MLS League ID: {mls_id}")
# 3. Standings
if mls_id:
print("\n3) Fetching MLS standings...")
standings = rapi.get_standings(mls_id)
_pp("MLS Standings", standings)
# 4. Search for Toronto FC
print("\n4) Searching for 'Toronto FC'...")
tfc_data = rapi.search_teams("Toronto FC")
_pp("TFC search results", tfc_data)
tfc_id = None
resp = tfc_data.get("response") if isinstance(tfc_data, dict) else None
if isinstance(resp, list):
for item in resp:
if isinstance(item, dict):
tfc_id = item.get("id") or item.get("primaryId")
if tfc_id:
tfc_id = int(tfc_id)
break
print(f"\n TFC Team ID: {tfc_id}")
# 5. Squad
if tfc_id:
print("\n5) Fetching TFC squad...")
squad = rapi.get_squad(tfc_id)
_pp("TFC Squad", squad)
# 6. Live matches (just check it works)
print("\n6) Checking live matches endpoint...")
live = rapi.get_live_matches()
resp = live.get("response") if isinstance(live, dict) else None
count = len(resp) if isinstance(resp, list) else 0
print(f" Live matches right now: {count}")
# 7. Trending news
print("\n7) Fetching trending news...")
news_data = rapi.get_trending_news()
_pp("Trending News", news_data)
print("\n" + "="*60)
print(" ALL CHECKS PASSED")
print("="*60)
if __name__ == "__main__":
main()

View File

@@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""Quick verification of Nike DB contents."""
import os
from dotenv import load_dotenv
import psycopg2
load_dotenv('/home/robert/gitea/nike/.env')
conn = psycopg2.connect(
host=os.getenv('DB_HOST'),
port=int(os.getenv('DB_PORT', 5432)),
user=os.getenv('DB_USER'),
password=os.getenv('DB_PASSWORD'),
dbname='nike',
)
cur = conn.cursor()
tables = [
'leagues', 'teams', 'players', 'fixtures', 'standings',
'match_stats', 'match_events', 'player_season_stats',
'player_match_stats', 'followed_entities',
]
print("Nike DB Contents")
print("-" * 40)
for table in tables:
cur.execute(f"SELECT COUNT(*) FROM {table}")
count = cur.fetchone()[0]
status = "" if count > 0 else ""
print(f" {status} {table:<28} {count:>6} rows")
# TFC summary
print()
cur.execute("""
SELECT t.name, COUNT(p.id) AS players
FROM teams t
LEFT JOIN players p ON p.current_team_id = t.id
WHERE t.is_followed = TRUE
GROUP BY t.name
""")
for row in cur.fetchall():
print(f"{row[0]}: {row[1]} players in roster")
cur.execute("""
SELECT COUNT(*) FROM fixtures f
JOIN teams t ON (t.id = f.home_team_id OR t.id = f.away_team_id)
WHERE t.is_followed = TRUE
""")
fix_count = cur.fetchone()[0]
print(f" 📅 Followed team fixtures: {fix_count}")
cur.execute("""
SELECT match_date::date, home.name, away.name, home_goals, away_goals, status
FROM fixtures f
JOIN teams home ON home.id = f.home_team_id
JOIN teams away ON away.id = f.away_team_id
JOIN teams ft ON (ft.id = f.home_team_id OR ft.id = f.away_team_id)
WHERE ft.is_followed = TRUE AND f.match_date >= NOW()
ORDER BY f.match_date ASC
LIMIT 3
""")
upcoming = cur.fetchall()
if upcoming:
print()
print(" Next fixtures:")
for row in upcoming:
print(f" {row[0]} {row[1]} vs {row[2]} ({row[5]})")
cur.close()
conn.close()