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 ──────────────────────────────────────── # ── Followed teams ────────────────────────────────────────
# Comma-separated list of "Team Name:League Name" pairs. # Comma-separated list of "Team Name:League Name" pairs.
NIKE_TEAMS=Toronto FC:MLS, Arsenal:Premier League 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. 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 Project #205
--- ---
@@ -13,61 +13,53 @@ Project #205
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ nike/server.py — single process on 0.0.0.0:{PORT} │ │ 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 │ │ GET /api/* → Dashboard JSON API │
│ /mcp/ → FastMCP HTTP (streamable) │ │ /mcp/ → FastMCP HTTP (streamable) │
└──────────┬──────────────────────────────────────────────┘ └──────────┬──────────────────────────────────────────────┘
nike/rapidapi.py nike/sportsdb.py ←→ nike/db.py (PostgreSQL cache)
(free-api-live-football-data client) (TheSportsDB client)
RapidAPI TheSportsDB API
(free-api-live-football-data.p.rapidapi.com)
``` ```
### Module responsibilities ### Module responsibilities
| Module | Role | | Module | Role |
|--------|------| |--------|------|
| `nike/config.py` | Centralised settings from `.env` (API keys, constants) | | `nike/config.py` | Centralised settings from `.env` (API key, DB, server) |
| `nike/rapidapi.py` | RapidAPI client with TTL cache (live data backend) | | `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/server.py` | FastAPI app: MCP tools, dashboard routes, mounts MCP ASGI |
| `nike/templates/dashboard.html` | Live status dashboard (Bootstrap 5, dark theme) | | `schema.sql` | Cache database DDL |
### 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 |
### MCP Tools ### MCP Tools
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `search(query)` | Universal search across teams, players, leagues, matches | | `get_team_info(team_name)` | Team profile: stadium, capacity, location, founded year, colors |
| `live_scores()` | All currently live matches worldwide | | `get_roster(team_name)` | Current squad grouped by position |
| `fixtures(league, date)` | Matches by league and/or date | | `get_player_info(player_name)` | Player profile: position, nationality, DOB, team, status |
| `standings(league)` | Full league table | | `get_fixtures(team_name, status)` | Recent results and upcoming matches (`status`: all/upcoming/past) |
| `team_info(team)` | Team profile + squad roster | | `get_standings(league, season)` | Full league table with points, goal difference, and form |
| `player_info(player)` | Player profile and details | | `get_match_result(team_name, match_date)` | Match result for a team on a specific date |
| `match_detail(event_id)` | Full match: score, stats, lineups, venue, referee | | `get_match_detail(event_id)` ★ | Deep match stats, lineup, and timeline |
| `head_to_head(event_id)` | H2H history for a matchup | | `get_livescores()` ★ | Current live soccer scores worldwide |
| `top_players(league, stat)` | Top scorers / assists / rated |
| `transfers(league_or_team, scope)` | Transfer activity | ★ Requires a premium TheSportsDB key.
| `news(scope, name)` | Trending, league, or team news |
The dashboard derives its tool list from these registrations via `GET /api/tools`, so it stays in sync automatically.
--- ---
## Dashboard ## 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 | | 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 | | `/tools` | Interactive tool runner — pick a tool, fill in parameters, inspect raw output |
### Build (required before serving via FastAPI) ### Build (required before serving via FastAPI)
@@ -101,12 +93,12 @@ The dev dashboard is at `http://localhost:5173`.
### Prerequisites ### Prerequisites
- Python >= 3.11 - 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 ### Install
```bash ```bash
cd ~/gitea/nike
python3 -m venv ~/env/nike python3 -m venv ~/env/nike
source ~/env/nike/bin/activate source ~/env/nike/bin/activate
pip install -e . pip install -e .
@@ -114,19 +106,15 @@ pip install -e .
### Configure ### 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 ### Provision the cache database
RAPIDAPI_KEY=<your-rapidapi-key>
```
### Verify API connectivity
```bash ```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?"* - *"What matches are live right now?"*
- *"Show the MLS standings"* - *"Show the MLS standings"*
- *"Who plays for Toronto FC?"* - *"Who plays for Toronto FC?"*
- *"Who's the MLS top scorer?"*
- *"Any transfer news for Inter Miami?"*
- *"Tell me about Federico Bernardeschi"* - *"Tell me about Federico Bernardeschi"*
--- ---
@@ -181,12 +167,8 @@ Once connected, you can ask questions like:
| Script | Purpose | | Script | Purpose |
|--------|---------| |--------|---------|
| `scripts/test_rapidapi.py` | Verify RapidAPI connectivity and fetch sample data | | `scripts/apply_schema.py` | Apply `schema.sql` to provision the cache database |
| `scripts/test_db.py` | Verify PostgreSQL connectivity (legacy) | | `scripts/discover_sportsdb.py` | Explore TheSportsDB API responses |
| `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) |
--- ---
@@ -197,41 +179,26 @@ nike/
├── .env # Secrets (not committed) ├── .env # Secrets (not committed)
├── pyproject.toml # Package metadata & dependencies ├── pyproject.toml # Package metadata & dependencies
├── run.py # Entrypoint: python run.py ├── run.py # Entrypoint: python run.py
├── schema.sql # Database DDL (legacy) ├── schema.sql # Cache database DDL
├── nike.service # systemd unit file ├── nike.service # systemd unit file
├── nike/ ├── nike/
│ ├── __init__.py │ ├── __init__.py
│ ├── config.py # Settings from .env │ ├── config.py # Settings from .env
│ ├── rapidapi.py # RapidAPI client (active backend) │ ├── sportsdb.py # TheSportsDB client (live backend)
│ ├── api_football.py # API-Football v3 client (legacy) │ ├── db.py # PostgreSQL pool + active cache
│ ├── db.py # DB pool + queries (legacy) │ ├── logging_config.py # Structured logging setup
── sync.py # API → DB sync logic (legacy) ── server.py # FastAPI + MCP server
│ ├── server.py # FastAPI + MCP server ├── dashboard/ # SvelteKit + DaisyUI dashboard
│ └── templates/
│ └── dashboard.html # Status dashboard
└── scripts/ └── scripts/
├── test_rapidapi.py # RapidAPI smoke test ├── apply_schema.py # Provision the cache database
── apply_schema.py # (legacy) ── discover_sportsdb.py # API exploration
├── pull_tfc.py # (legacy)
├── test_api.py # (legacy)
├── test_db.py # (legacy)
└── verify_db.py # (legacy)
``` ```
--- ---
## API Quota ## Caching
The free-api-live-football-data RapidAPI pricing: 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.
| 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.

File diff suppressed because one or more lines are too long

View File

@@ -7,14 +7,12 @@
"": { "": {
"name": "nike-dashboard", "name": "nike-dashboard",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"@melt-ui/svelte": "^0.83.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3", "@tailwindcss/vite": "^4.1.3",
"daisyui": "^5",
"svelte": "^5.25.3", "svelte": "^5.25.3",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3", "tailwindcss": "^4.1.3",
@@ -438,40 +436,11 @@
"node": ">=18" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
@@ -481,6 +450,7 @@
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
@@ -490,6 +460,7 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
@@ -497,33 +468,19 @@
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "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": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -865,6 +822,7 @@
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"peerDependencies": { "peerDependencies": {
"acorn": "^8.9.0" "acorn": "^8.9.0"
} }
@@ -957,14 +915,6 @@
"vite": "^6.0.0" "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": { "node_modules/@tailwindcss/node": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
@@ -1231,17 +1181,20 @@
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "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": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "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": { "node_modules/@typescript-eslint/types": {
"version": "8.57.2", "version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
"dev": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@@ -1254,6 +1207,7 @@
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1265,6 +1219,7 @@
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -1273,6 +1228,7 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -1296,6 +1252,7 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -1309,6 +1266,15 @@
"node": ">= 0.6" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1335,14 +1301,6 @@
"node": ">=0.10.0" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1355,7 +1313,8 @@
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.6.4", "version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", "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": { "node_modules/enhanced-resolve": {
"version": "5.20.1", "version": "5.20.1",
@@ -1414,12 +1373,14 @@
"node_modules/esm-env": { "node_modules/esm-env": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "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": { "node_modules/esrap": {
"version": "2.2.4", "version": "2.2.4",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz",
"integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==",
"dev": true,
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/sourcemap-codec": "^1.4.15",
"@typescript-eslint/types": "^8.2.0" "@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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1474,6 +1427,7 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"dependencies": { "dependencies": {
"@types/estree": "^1.0.6" "@types/estree": "^1.0.6"
} }
@@ -1748,12 +1702,14 @@
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
@@ -1782,23 +1738,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1965,6 +1904,7 @@
"version": "5.55.0", "version": "5.55.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz",
"integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==",
"dev": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2010,11 +1950,6 @@
"typescript": ">=5.0.0" "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": { "node_modules/tailwindcss": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
@@ -2062,7 +1997,9 @@
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
@@ -2168,7 +2105,8 @@
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "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/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3", "@tailwindcss/vite": "^4.1.3",
"daisyui": "^5",
"svelte": "^5.25.3", "svelte": "^5.25.3",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3", "tailwindcss": "^4.1.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.2.5" "vite": "^6.2.5"
},
"dependencies": {
"@melt-ui/svelte": "^0.83.0"
} }
} }

View File

@@ -1,9 +1,19 @@
@import "tailwindcss"; @import "tailwindcss";
/* Class-based dark mode: toggled via .dark on <html>. */ @plugin "daisyui" {
@custom-variant dark (&:where(.dark, .dark *)); themes: light --default, dark --prefersdark;
}
@theme {
--color-pitch: #16a34a; /* Keep the "pitch" green identity as the primary color in both themes. */
--color-pitch-dark: #15803d; @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" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nike — Football Data Platform</title> <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> <script>
try { try {
const stored = localStorage.getItem('nike-theme'); const stored = localStorage.getItem('nike-theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches; if (stored === 'dark' || stored === 'light') {
if (stored === 'dark' || (stored !== 'light' && systemDark)) { document.documentElement.setAttribute('data-theme', stored);
document.documentElement.classList.add('dark');
} }
} catch (_) {} } catch (_) {}
</script> </script>

View File

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

View File

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

View File

@@ -13,9 +13,13 @@
// Derived: dark when explicitly set, otherwise follow system. // Derived: dark when explicitly set, otherwise follow system.
let isDark = $derived(override !== null ? override === 'dark' : systemDark); 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(() => { $effect(() => {
document.documentElement.classList.toggle('dark', isDark); if (override === null) {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', override);
}
}); });
function toggleTheme() { function toggleTheme() {
@@ -80,22 +84,19 @@
]; ];
</script> </script>
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100"> <div class="min-h-screen bg-base-200 text-base-content">
<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"> <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="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-green-500 text-lg leading-none"></span> <span class="text-primary text-lg leading-none"></span>
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Nike</span> <span class="font-semibold tracking-tight">Nike</span>
<span class="text-gray-500 text-sm hidden sm:inline">Football Data Platform</span> <span class="text-base-content/50 text-sm hidden sm:inline">Football Data Platform</span>
</div> </div>
<nav class="flex gap-1 ml-2"> <nav class="flex gap-1 ml-2">
{#each nav as item} {#each nav as item}
<a <a
href={item.href} href={item.href}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname === class="btn btn-sm {$page.url.pathname === item.href ? 'btn-primary' : 'btn-ghost'}"
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'}"
> >
{item.label} {item.label}
</a> </a>
@@ -103,7 +104,7 @@
</nav> </nav>
<button <button
onclick={toggleTheme} 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'} title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
> >
{isDark ? 'Light' : 'Dark'} {isDark ? 'Light' : 'Dark'}

View File

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

View File

@@ -1,98 +1,49 @@
<script lang="ts"> <script lang="ts">
import { createTooltip, melt } from '@melt-ui/svelte'; import { onMount } from 'svelte';
import { runTool } from '$lib/api'; import { fetchTools, runTool } from '$lib/api';
import type { ToolInfo, ToolParam } from '$lib/types';
// ── Tool definitions ──────────────────────────────────── // ── Frontend-only form niceties, keyed by backend param name ──
// The backend tool schema carries name/type/default/required; labels,
type ParamType = 'text' | 'number' | 'date' | 'select'; // placeholders, select options, and the input widget live here so the
// API stays presentation-free.
interface Param { interface ParamUi {
key: string; label?: string;
label: string; input?: 'date' | 'select';
type: ParamType;
default: string;
options?: string[]; options?: string[];
placeholder?: 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 { function uiFor(p: ToolParam): ParamUi {
name: string; return PARAM_UI[p.name] ?? {};
description: string; }
premium: boolean; function labelFor(p: ToolParam) {
params: Param[]; 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 ──────────────────────────────────────────────── // ── State ────────────────────────────────────────────────
let selectedTool = $state<ToolDef>(TOOLS[0]); let tools = $state<ToolInfo[]>([]);
let selectedTool = $state<ToolInfo | null>(null);
let paramValues = $state<Record<string, string>>({}); let paramValues = $state<Record<string, string>>({});
let running = $state(false); let running = $state(false);
let result = $state<string | null>(null); let result = $state<string | null>(null);
@@ -107,26 +58,30 @@
} }
let history = $state<HistoryEntry[]>([]); let history = $state<HistoryEntry[]>([]);
function selectTool(tool: ToolDef) { function selectTool(tool: ToolInfo) {
selectedTool = tool; selectedTool = tool;
result = null; result = null;
resultError = 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 onMount(async () => {
selectTool(TOOLS[0]); const r = await fetchTools();
tools = r.tools;
if (tools.length > 0) selectTool(tools[0]);
});
async function submit() { async function submit() {
if (!selectedTool) return;
running = true; running = true;
result = null; result = null;
resultError = null; resultError = null;
const args: Record<string, unknown> = {}; const args: Record<string, unknown> = {};
for (const p of selectedTool.params) { for (const p of selectedTool.params) {
const val = paramValues[p.key]; const val = paramValues[p.name];
if (val === '') continue; if (val === '') continue;
args[p.key] = p.type === 'number' ? Number(val) : val; args[p.name] = p.type === 'integer' ? Number(val) : val;
} }
const snapshot = { ...paramValues }; const snapshot = { ...paramValues };
@@ -149,7 +104,7 @@
} }
function loadHistory(entry: HistoryEntry) { 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; if (!tool) return;
selectTool(tool); selectTool(tool);
// selectTool resets paramValues, restore after microtask // selectTool resets paramValues, restore after microtask
@@ -168,190 +123,167 @@
function fmtTime(d: Date) { function fmtTime(d: Date) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); 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> </script>
<div class="space-y-5"> <div class="space-y-5">
<div> <div>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Tool Runner</h1> <h1 class="text-lg font-semibold">Tool Runner</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p class="text-sm text-base-content/60 mt-1">
Run MCP tools interactively and inspect raw API responses. Useful for spotting strange API Run MCP tools interactively and inspect raw API responses. Useful for spotting strange API
results. results.
</p> </p>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"> {#if !selectedTool}
<!-- Left: selector + form + result --> <div class="flex items-center gap-2 text-base-content/60 text-sm">
<div class="lg:col-span-2 space-y-4"> <span class="loading loading-spinner loading-sm"></span> Loading tools…
<!-- Tool selector --> </div>
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4"> {:else}
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Select Tool</h2> <div class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
<div class="flex flex-wrap gap-2"> <!-- Left: selector + form + result -->
{#each TOOLS as tool} <div class="lg:col-span-2 space-y-4">
<button <!-- Tool selector -->
onclick={() => selectTool(tool)} <div class="card bg-base-100 border border-base-300">
class="px-3 py-1.5 rounded text-sm font-medium transition-colors <div class="card-body p-4">
{selectedTool.name === tool.name <h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider mb-1">Select Tool</h2>
? 'bg-green-700 text-white' <div class="flex flex-wrap gap-2">
: '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'}" {#each tools as tool}
> <button
{tool.name} onclick={() => selectTool(tool)}
{#if tool.premium} class="btn btn-sm {selectedTool.name === tool.name ? 'btn-primary' : 'btn-soft'}"
<span use:melt={$premTrigger} class="ml-1 text-amber-500 dark:text-amber-400 cursor-help"></span> >
{/if} {tool.name}
</button> {#if tool.premium}
{/each} <span
</div> class="tooltip cursor-help text-warning"
</div> data-tip="Requires a premium TheSportsDB key"
>
<!-- 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"> </span>
<div> {/if}
<div class="flex items-center gap-2"> </button>
<h2 class="text-sm font-semibold text-gray-900 dark:text-white font-mono">{selectedTool.name}</h2> {/each}
{#if selectedTool.premium} </div>
<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>
{/if}
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{selectedTool.description}</p>
</div> </div>
{#if selectedTool.params.length > 0} <!-- Parameter form -->
<div class="space-y-3"> <div class="card bg-base-100 border border-base-300">
{#each selectedTool.params as param} <div class="card-body p-4 gap-4">
<div> <div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5" for={param.key}> <div class="flex items-center gap-2">
{param.label} <h2 class="text-sm font-semibold font-mono">{selectedTool.name}</h2>
</label> {#if selectedTool.premium}
{#if param.type === 'select'} <span class="badge badge-warning badge-sm">Premium</span>
<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"
>
{#each 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"
/>
{/if} {/if}
</div> </div>
{/each} <p class="text-xs text-base-content/60 mt-1 whitespace-pre-line">{selectedTool.description}</p>
</div> </div>
{:else}
<p class="text-xs text-gray-500 italic">No parameters — just click Run.</p>
{/if}
<button {#if selectedTool.params.length > 0}
onclick={submit} <div class="space-y-3">
disabled={running} {#each selectedTool.params as param}
class="px-4 py-2 rounded-md bg-green-700 hover:bg-green-600 text-white text-sm <div>
font-medium disabled:opacity-50 transition-colors flex items-center gap-2" <label class="label text-xs font-medium mb-1" for={param.name}>
> {labelFor(param)}
{#if running} </label>
<span class="inline-block w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></span> {#if uiFor(param).input === 'select'}
Running… <select
{:else} id={param.name}
Run Tool bind:value={paramValues[param.name]}
{/if} class="select select-sm w-full"
</button> >
</div> {#each uiFor(param).options ?? [] as opt}
<option value={opt}>{opt}</option>
<!-- Result --> {/each}
{#if result !== null || resultError !== null} </select>
<div {:else}
class="rounded-lg bg-white dark:bg-gray-900 border {resultError <input
? 'border-red-200 dark:border-red-800' id={param.name}
: 'border-gray-200 dark:border-gray-800'}" type={inputType(param)}
> placeholder={uiFor(param).placeholder ?? ''}
<div bind:value={paramValues[param.name]}
class="px-4 py-2.5 border-b {resultError class="input input-sm w-full"
? 'border-red-200 dark:border-red-800' />
: 'border-gray-200 dark:border-gray-800'} flex items-center gap-2" {/if}
> </div>
<span class="text-xs font-medium {resultError ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
{resultError ? 'Error' : 'Result'}
</span>
{#if result}
<span class="text-xs text-gray-500">
{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
max-h-[520px] overflow-y-auto leading-relaxed"
>{resultError ?? result}</pre>
</div>
{/if}
</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>
{#if history.length === 0}
<p class="px-4 py-5 text-sm text-gray-500">No queries yet.</p>
{:else}
<ul class="divide-y divide-gray-100 dark:divide-gray-800 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"
>
<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">
{entry.tool}
</code>
<span class="text-xs text-gray-500 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>
{/if}
{/each} {/each}
{#if !entry.ok} </div>
<div class="text-xs text-red-500 mt-0.5 truncate">{entry.output}</div> {:else}
{/if} <p class="text-xs text-base-content/60 italic">No parameters — just click Run.</p>
</button> {/if}
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
<!-- Melt UI tooltip for premium star --> <button onclick={submit} disabled={running} class="btn btn-primary btn-sm self-start">
{#if $premOpen} {#if running}
<div <span class="loading loading-spinner loading-xs"></span>
use:melt={$premContent} Running…
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" {:else}
> Run Tool
Requires a premium TheSportsDB key {/if}
</div> </button>
{/if} </div>
</div>
<!-- Result -->
{#if result !== null || resultError !== null}
<div class="card bg-base-100 border {resultError ? 'border-error' : 'border-base-300'}">
<div
class="px-4 py-2.5 border-b {resultError ? 'border-error' : 'border-base-300'} flex items-center gap-2"
>
<span class="text-xs font-medium {resultError ? 'text-error' : 'text-success'}">
{resultError ? 'Error' : 'Result'}
</span>
{#if result}
<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 overflow-x-auto
max-h-[520px] overflow-y-auto leading-relaxed"
>{resultError ?? result}</pre>
</div>
{/if}
</div>
<!-- Right: session history -->
<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-base-content/60">No queries yet.</p>
{:else}
<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-base-200 transition-colors"
>
<div class="flex items-center justify-between gap-2">
<code class="text-xs {entry.ok ? 'text-primary' : 'text-error'} truncate">
{entry.tool}
</code>
<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-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-error mt-0.5 truncate">{entry.output}</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</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 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 ────────────────────────────────────────────────
SERVER_HOST = os.getenv('NIKE_HOST') SERVER_HOST = os.getenv('NIKE_HOST')
SERVER_PORT = int(os.getenv('NIKE_PORT')) 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 Single process on 0.0.0.0:8000
/ → Bootstrap status dashboard / → SvelteKit dashboard (dashboard/build)
/api/* → Dashboard JSON API /api/* → Dashboard JSON API
/mcp → FastMCP HTTP endpoint (streamable-HTTP) /mcp → FastMCP HTTP endpoint (streamable-HTTP)
@@ -28,7 +28,6 @@ import uvicorn
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from prometheus_client import ( from prometheus_client import (
CONTENT_TYPE_LATEST, CONTENT_TYPE_LATEST,
CollectorRegistry, CollectorRegistry,
@@ -539,7 +538,7 @@ def get_match_result(team_name: str, match_date: str) -> str:
return "\n".join(lines) 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: def get_match_detail(event_id: int) -> str:
""" """
Get deep match detail: stats, lineup, and timeline for a specific match. 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) return "\n".join(lines)
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True), tags={"premium"})
def get_livescores() -> str: def get_livescores() -> str:
""" """
Get current live soccer scores worldwide. Get current live soccer scores worldwide.
@@ -731,9 +730,6 @@ def football_analyst() -> str:
_mcp_app = mcp.http_app(path="/") _mcp_app = mcp.http_app(path="/")
# ── FastAPI dashboard app ───────────────────────────────── # ── FastAPI dashboard app ─────────────────────────────────
_TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).parent / "templates")
)
@asynccontextmanager @asynccontextmanager
@@ -781,11 +777,15 @@ _SVELTE_BUILD = Path(__file__).parent.parent / "dashboard" / "build"
@dashboard.get("/", response_class=HTMLResponse) @dashboard.get("/", response_class=HTMLResponse)
async def index(request: Request): async def index():
svelte_index = _SVELTE_BUILD / "index.html" svelte_index = _SVELTE_BUILD / "index.html"
if svelte_index.exists(): if svelte_index.exists():
return FileResponse(str(svelte_index)) 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") @dashboard.get("/api/status")
@@ -797,19 +797,8 @@ async def api_status():
uptime_s = int((datetime.now(timezone.utc) - _SERVER_START).total_seconds()) 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" uptime_str = f"{uptime_s // 3600}h {(uptime_s % 3600) // 60}m {uptime_s % 60}s"
is_premium = config.SPORTSDB_KEY not in ('3', '') 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) db_status.setdefault("host", config.DB_HOST)
return JSONResponse({ return JSONResponse({
"database": db_status, "database": db_status,
@@ -820,7 +809,7 @@ async def api_status():
"endpoint": f"http://{config.SERVER_HOST}:{config.SERVER_PORT}/mcp", "endpoint": f"http://{config.SERVER_HOST}:{config.SERVER_PORT}/mcp",
"port": config.SERVER_PORT, "port": config.SERVER_PORT,
"uptime": uptime_str, "uptime": uptime_str,
"tool_count": len(tools), "tool_count": tool_count,
"premium": is_premium, "premium": is_premium,
}, },
"data": { "data": {
@@ -829,10 +818,31 @@ async def api_status():
"followed": [{"team": t[0], "league": t[1]} "followed": [{"team": t[0], "league": t[1]}
for t in config.FOLLOWED_TEAMS], 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") @dashboard.get("/api/logs")
async def api_logs(limit: int = 50): async def api_logs(limit: int = 50):
return JSONResponse({"logs": list(_REQUEST_LOG)[:limit]}) 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 ─────────────────────────────────────── # ── 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): class _RunRequest(BaseModel):
tool: str tool: str
@@ -908,11 +885,11 @@ class _TelemetryReport(BaseModel):
@dashboard.post("/api/run") @dashboard.post("/api/run")
async def api_run(body: _RunRequest): async def api_run(body: _RunRequest):
fn = _TOOLS.get(body.tool) tool = await mcp.get_tool(body.tool)
if fn is None: if tool is None:
return JSONResponse({"ok": False, "error": f"Unknown tool: {body.tool!r}"}, status_code=400) return JSONResponse({"ok": False, "error": f"Unknown tool: {body.tool!r}"}, status_code=400)
try: try:
result = fn(**body.args) result = tool.fn(**body.args)
return JSONResponse({"ok": True, "result": result}) return JSONResponse({"ok": True, "result": result})
except Exception as exc: except Exception as exc:
logger.error("Tool run failed: tool=%s args=%s", body.tool, body.args, exc_info=True) 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 = [ dependencies = [
"fastapi>=0.115", "fastapi>=0.115",
"uvicorn[standard]>=0.30", "uvicorn[standard]>=0.30",
"fastmcp>=2.0", "fastmcp>=3",
"psycopg2-binary>=2.9", "psycopg2-binary>=2.9",
"python-dotenv>=1.0", "python-dotenv>=1.0",
"requests>=2.32", "requests>=2.32",
"jinja2>=3.1",
"wsproto>=1.2", "wsproto>=1.2",
"prometheus-client>=0.20", "prometheus-client>=0.20",
] ]

View File

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