feat: migrate from RapidAPI to TheSportsDB with SvelteKit dashboard
- 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:
@@ -24,7 +24,3 @@ NIKE_TRUSTED_PROXY=*
|
||||
# ── Followed teams ────────────────────────────────────────
|
||||
# Comma-separated list of "Team Name:League Name" pairs.
|
||||
NIKE_TEAMS=Toronto FC:MLS, Arsenal:Premier League
|
||||
|
||||
# ── Legacy (not active) ───────────────────────────────────
|
||||
# NIKE_RAPIDAPI_KEY=
|
||||
# NIKE_API_FOOTBALL_KEY=
|
||||
|
||||
115
README.md
115
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
MCP server and web dashboard for football (soccer) data, with full MLS support.
|
||||
|
||||
Queries live data from [free-api-live-football-data](https://rapidapi.com/Creativesdev/api/free-api-live-football-data) (RapidAPI) and exposes it via MCP tools for conversational analysis and a Bootstrap status dashboard.
|
||||
Queries live data from [TheSportsDB](https://www.thesportsdb.com/) and exposes it via MCP tools for conversational analysis, with a SvelteKit dashboard for status and interactive testing. A PostgreSQL cache stores permanent data (teams, players, leagues, events) to minimise API calls.
|
||||
|
||||
Project #205
|
||||
---
|
||||
@@ -13,61 +13,53 @@ Project #205
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ nike/server.py — single process on 0.0.0.0:{PORT} │
|
||||
│ │
|
||||
│ GET / → Bootstrap dashboard (dashboard.html)│
|
||||
│ GET / → SvelteKit dashboard (dashboard/build)│
|
||||
│ GET /api/* → Dashboard JSON API │
|
||||
│ /mcp/ → FastMCP HTTP (streamable) │
|
||||
└──────────┬──────────────────────────────────────────────┘
|
||||
│
|
||||
nike/rapidapi.py
|
||||
(free-api-live-football-data client)
|
||||
nike/sportsdb.py ←→ nike/db.py (PostgreSQL cache)
|
||||
(TheSportsDB client)
|
||||
│
|
||||
RapidAPI
|
||||
(free-api-live-football-data.p.rapidapi.com)
|
||||
TheSportsDB API
|
||||
```
|
||||
|
||||
### Module responsibilities
|
||||
|
||||
| Module | Role |
|
||||
|--------|------|
|
||||
| `nike/config.py` | Centralised settings from `.env` (API keys, constants) |
|
||||
| `nike/rapidapi.py` | RapidAPI client with TTL cache (live data backend) |
|
||||
| `nike/config.py` | Centralised settings from `.env` (API key, DB, server) |
|
||||
| `nike/sportsdb.py` | TheSportsDB client with in-memory TTL cache (live backend) |
|
||||
| `nike/db.py` | PostgreSQL connection pool + active cache layer for permanent data |
|
||||
| `nike/server.py` | FastAPI app: MCP tools, dashboard routes, mounts MCP ASGI |
|
||||
| `nike/templates/dashboard.html` | Live status dashboard (Bootstrap 5, dark theme) |
|
||||
|
||||
### Legacy modules (preserved, not active)
|
||||
|
||||
| Module | Role |
|
||||
|--------|------|
|
||||
| `nike/api_football.py` | API-Football v3 client (original backend) |
|
||||
| `nike/db.py` | PostgreSQL connection pool and query helpers |
|
||||
| `nike/sync.py` | API → DB sync pipeline |
|
||||
| `schema.sql` | Database DDL |
|
||||
| `schema.sql` | Cache database DDL |
|
||||
|
||||
### MCP Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search(query)` | Universal search across teams, players, leagues, matches |
|
||||
| `live_scores()` | All currently live matches worldwide |
|
||||
| `fixtures(league, date)` | Matches by league and/or date |
|
||||
| `standings(league)` | Full league table |
|
||||
| `team_info(team)` | Team profile + squad roster |
|
||||
| `player_info(player)` | Player profile and details |
|
||||
| `match_detail(event_id)` | Full match: score, stats, lineups, venue, referee |
|
||||
| `head_to_head(event_id)` | H2H history for a matchup |
|
||||
| `top_players(league, stat)` | Top scorers / assists / rated |
|
||||
| `transfers(league_or_team, scope)` | Transfer activity |
|
||||
| `news(scope, name)` | Trending, league, or team news |
|
||||
| `get_team_info(team_name)` | Team profile: stadium, capacity, location, founded year, colors |
|
||||
| `get_roster(team_name)` | Current squad grouped by position |
|
||||
| `get_player_info(player_name)` | Player profile: position, nationality, DOB, team, status |
|
||||
| `get_fixtures(team_name, status)` | Recent results and upcoming matches (`status`: all/upcoming/past) |
|
||||
| `get_standings(league, season)` | Full league table with points, goal difference, and form |
|
||||
| `get_match_result(team_name, match_date)` | Match result for a team on a specific date |
|
||||
| `get_match_detail(event_id)` ★ | Deep match stats, lineup, and timeline |
|
||||
| `get_livescores()` ★ | Current live soccer scores worldwide |
|
||||
|
||||
★ Requires a premium TheSportsDB key.
|
||||
|
||||
The dashboard derives its tool list from these registrations via `GET /api/tools`, so it stays in sync automatically.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard
|
||||
|
||||
The web dashboard is a SvelteKit 2 / Svelte 5 / Tailwind CSS 4 app in `dashboard/`.
|
||||
The web dashboard is a SvelteKit 2 / Svelte 5 / Tailwind CSS 4 + DaisyUI 5 app in `dashboard/`.
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `/` | System status: DB, API, MCP health cards; followed teams; tools list; request log |
|
||||
| `/` | System status: cache, API, and MCP health cards; followed teams; tools list; request log |
|
||||
| `/tools` | Interactive tool runner — pick a tool, fill in parameters, inspect raw output |
|
||||
|
||||
### Build (required before serving via FastAPI)
|
||||
@@ -101,12 +93,12 @@ The dev dashboard is at `http://localhost:5173`.
|
||||
### Prerequisites
|
||||
|
||||
- Python >= 3.11
|
||||
- A RapidAPI key for [free-api-live-football-data](https://rapidapi.com/Creativesdev/api/free-api-live-football-data)
|
||||
- PostgreSQL (for the cache; see `schema.sql`)
|
||||
- A TheSportsDB key (`3` is the free test key; premium tools require a paid key)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
cd ~/gitea/nike
|
||||
python3 -m venv ~/env/nike
|
||||
source ~/env/nike/bin/activate
|
||||
pip install -e .
|
||||
@@ -114,19 +106,15 @@ pip install -e .
|
||||
|
||||
### Configure
|
||||
|
||||
Create (or edit) `.env` in the project root:
|
||||
Copy `.env.example` to `.env` and fill in your values (TheSportsDB key, database connection, server host/port). See `.env.example` for the full list of `NIKE_*` variables.
|
||||
|
||||
```env
|
||||
RAPIDAPI_KEY=<your-rapidapi-key>
|
||||
```
|
||||
|
||||
### Verify API connectivity
|
||||
### Provision the cache database
|
||||
|
||||
```bash
|
||||
python scripts/test_rapidapi.py
|
||||
python scripts/apply_schema.py
|
||||
```
|
||||
|
||||
This searches for MLS, fetches standings, finds Toronto FC, and pulls the squad roster.
|
||||
This applies `schema.sql` to the PostgreSQL database configured in `.env`.
|
||||
|
||||
---
|
||||
|
||||
@@ -171,8 +159,6 @@ Once connected, you can ask questions like:
|
||||
- *"What matches are live right now?"*
|
||||
- *"Show the MLS standings"*
|
||||
- *"Who plays for Toronto FC?"*
|
||||
- *"Who's the MLS top scorer?"*
|
||||
- *"Any transfer news for Inter Miami?"*
|
||||
- *"Tell me about Federico Bernardeschi"*
|
||||
|
||||
---
|
||||
@@ -181,12 +167,8 @@ Once connected, you can ask questions like:
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/test_rapidapi.py` | Verify RapidAPI connectivity and fetch sample data |
|
||||
| `scripts/test_db.py` | Verify PostgreSQL connectivity (legacy) |
|
||||
| `scripts/test_api.py` | Verify API-Football connectivity (legacy) |
|
||||
| `scripts/apply_schema.py` | Apply database schema (legacy) |
|
||||
| `scripts/pull_tfc.py` | Full TFC data sync via API-Football (legacy) |
|
||||
| `scripts/verify_db.py` | Print DB row counts (legacy) |
|
||||
| `scripts/apply_schema.py` | Apply `schema.sql` to provision the cache database |
|
||||
| `scripts/discover_sportsdb.py` | Explore TheSportsDB API responses |
|
||||
|
||||
---
|
||||
|
||||
@@ -197,41 +179,26 @@ nike/
|
||||
├── .env # Secrets (not committed)
|
||||
├── pyproject.toml # Package metadata & dependencies
|
||||
├── run.py # Entrypoint: python run.py
|
||||
├── schema.sql # Database DDL (legacy)
|
||||
├── schema.sql # Cache database DDL
|
||||
├── nike.service # systemd unit file
|
||||
├── nike/
|
||||
│ ├── __init__.py
|
||||
│ ├── config.py # Settings from .env
|
||||
│ ├── rapidapi.py # RapidAPI client (active backend)
|
||||
│ ├── api_football.py # API-Football v3 client (legacy)
|
||||
│ ├── db.py # DB pool + queries (legacy)
|
||||
│ ├── sync.py # API → DB sync logic (legacy)
|
||||
│ ├── server.py # FastAPI + MCP server
|
||||
│ └── templates/
|
||||
│ └── dashboard.html # Status dashboard
|
||||
│ ├── sportsdb.py # TheSportsDB client (live backend)
|
||||
│ ├── db.py # PostgreSQL pool + active cache
|
||||
│ ├── logging_config.py # Structured logging setup
|
||||
│ └── server.py # FastAPI + MCP server
|
||||
├── dashboard/ # SvelteKit + DaisyUI dashboard
|
||||
└── scripts/
|
||||
├── test_rapidapi.py # RapidAPI smoke test
|
||||
├── apply_schema.py # (legacy)
|
||||
├── pull_tfc.py # (legacy)
|
||||
├── test_api.py # (legacy)
|
||||
├── test_db.py # (legacy)
|
||||
└── verify_db.py # (legacy)
|
||||
├── apply_schema.py # Provision the cache database
|
||||
└── discover_sportsdb.py # API exploration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Quota
|
||||
## Caching
|
||||
|
||||
The free-api-live-football-data RapidAPI pricing:
|
||||
|
||||
| Plan | Price | Requests/Month |
|
||||
|------|-------|----------------|
|
||||
| Basic (Free) | $0 | 100 |
|
||||
| Pro | $9.99/mo | 20,000 |
|
||||
| Ultra | $19.99/mo | 200,000 |
|
||||
| Mega | $49.99/mo | 500,000 |
|
||||
|
||||
Nike uses a 5-minute in-memory TTL cache to minimize API calls during conversations.
|
||||
Nike caches in two layers to minimise TheSportsDB calls: a short-lived in-memory TTL cache in `nike/sportsdb.py`, and a PostgreSQL cache (`nike/db.py`) for permanent data such as teams, players, leagues, and events. The dashboard's **Clear Cache** button (and `POST /api/cache/invalidate`) flushes both.
|
||||
|
||||
|
||||
|
||||
|
||||
144
dashboard/package-lock.json
generated
144
dashboard/package-lock.json
generated
@@ -7,14 +7,12 @@
|
||||
"": {
|
||||
"name": "nike-dashboard",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@melt-ui/svelte": "^0.83.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"daisyui": "^5",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
@@ -438,40 +436,11 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
|
||||
"integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
@@ -481,6 +450,7 @@
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
@@ -490,6 +460,7 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
@@ -497,33 +468,19 @@
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@melt-ui/svelte": {
|
||||
"version": "0.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.83.0.tgz",
|
||||
"integrity": "sha512-E7QT+8YSftz+Hdk1W0hNR3f+cnaF2COMWkStn+2u4vk0RO1I9mXRJl+bJD6uhYaH146oxEB+5elu/ABbv6rpsA==",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.3.1",
|
||||
"@floating-ui/dom": "^1.4.5",
|
||||
"@internationalized/date": "^3.5.0",
|
||||
"dequal": "^2.0.3",
|
||||
"focus-trap": "^7.5.2",
|
||||
"nanoid": "^5.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.118"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -865,6 +822,7 @@
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
|
||||
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
}
|
||||
@@ -957,14 +915,6 @@
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.20",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz",
|
||||
"integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
@@ -1231,17 +1181,20 @@
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
|
||||
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -1254,6 +1207,7 @@
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1265,6 +1219,7 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -1273,6 +1228,7 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -1296,6 +1252,7 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -1309,6 +1266,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.5.23",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.23.tgz",
|
||||
"integrity": "sha512-xuheNUSL4T6ZVtWXoioqcNkjoyGX85QTDz4HTw2aBPfqk4fuMjax5HDo8qCmpV6M1YN8bGvfx5BpYCoDeRlt+A==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1335,14 +1301,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1355,7 +1313,8 @@
|
||||
"node_modules/devalue": {
|
||||
"version": "5.6.4",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
|
||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="
|
||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.1",
|
||||
@@ -1414,12 +1373,14 @@
|
||||
"node_modules/esm-env": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/esrap": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz",
|
||||
"integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
"@typescript-eslint/types": "^8.2.0"
|
||||
@@ -1442,14 +1403,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/focus-trap": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
|
||||
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
|
||||
"dependencies": {
|
||||
"tabbable": "^6.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1474,6 +1427,7 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
@@ -1748,12 +1702,14 @@
|
||||
"node_modules/locate-character": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
@@ -1782,23 +1738,6 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "5.1.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz",
|
||||
"integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1965,6 +1904,7 @@
|
||||
"version": "5.55.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz",
|
||||
"integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -2010,11 +1950,6 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
@@ -2062,7 +1997,9 @@
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
@@ -2168,7 +2105,8 @@
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,11 @@
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"daisyui": "^5",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@melt-ui/svelte": "^0.83.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Class-based dark mode: toggled via .dark on <html>. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-pitch: #16a34a;
|
||||
--color-pitch-dark: #15803d;
|
||||
@plugin "daisyui" {
|
||||
themes: light --default, dark --prefersdark;
|
||||
}
|
||||
|
||||
/* Keep the "pitch" green identity as the primary color in both themes. */
|
||||
@plugin "daisyui/theme" {
|
||||
name: "light";
|
||||
default: true;
|
||||
--color-primary: #15803d;
|
||||
--color-primary-content: #ffffff;
|
||||
}
|
||||
@plugin "daisyui/theme" {
|
||||
name: "dark";
|
||||
prefersdark: true;
|
||||
--color-primary: #16a34a;
|
||||
--color-primary-content: #ffffff;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Nike — Football Data Platform</title>
|
||||
<!-- Apply theme before first paint to prevent flash of wrong theme. -->
|
||||
<!-- Apply a manual theme override before first paint to prevent a flash.
|
||||
With no override, daisyUI follows the system via its prefersdark media query. -->
|
||||
<script>
|
||||
try {
|
||||
const stored = localStorage.getItem('nike-theme');
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (stored === 'dark' || (stored !== 'light' && systemDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', stored);
|
||||
}
|
||||
} catch (_) {}
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LogsResponse, RunResult, StatusResponse } from './types';
|
||||
import type { LogsResponse, RunResult, StatusResponse, ToolsResponse } from './types';
|
||||
|
||||
interface TelemetryReport {
|
||||
type: string;
|
||||
@@ -26,6 +26,12 @@ export async function fetchStatus(): Promise<StatusResponse> {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchTools(): Promise<ToolsResponse> {
|
||||
const r = await fetch('/api/tools');
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchLogs(limit = 50): Promise<LogsResponse> {
|
||||
const r = await fetch(`/api/logs?limit=${limit}`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
|
||||
@@ -29,11 +29,22 @@ export interface DataStatus {
|
||||
followed: Array<{ team: string; league: string }>;
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
export interface ToolParam {
|
||||
name: string;
|
||||
type: string;
|
||||
default: string | number | null;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
readonly: boolean;
|
||||
premium?: boolean;
|
||||
premium: boolean;
|
||||
params: ToolParam[];
|
||||
}
|
||||
|
||||
export interface ToolsResponse {
|
||||
tools: ToolInfo[];
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
@@ -41,7 +52,6 @@ export interface StatusResponse {
|
||||
api: ApiStatus;
|
||||
mcp: McpStatus;
|
||||
data: DataStatus;
|
||||
tools: Tool[];
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
|
||||
@@ -13,9 +13,13 @@
|
||||
// Derived: dark when explicitly set, otherwise follow system.
|
||||
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
|
||||
|
||||
// Keep <html> class in sync with isDark whenever it changes.
|
||||
// Apply a manual override via data-theme; clearing it lets daisyUI follow the system.
|
||||
$effect(() => {
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
if (override === null) {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', override);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
@@ -80,22 +84,19 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||
<header class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10">
|
||||
<div class="min-h-screen bg-base-200 text-base-content">
|
||||
<header class="border-b border-base-300 bg-base-100/80 backdrop-blur sticky top-0 z-10">
|
||||
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-green-500 text-lg leading-none">⚽</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Nike</span>
|
||||
<span class="text-gray-500 text-sm hidden sm:inline">Football Data Platform</span>
|
||||
<span class="text-primary text-lg leading-none">⚽</span>
|
||||
<span class="font-semibold tracking-tight">Nike</span>
|
||||
<span class="text-base-content/50 text-sm hidden sm:inline">Football Data Platform</span>
|
||||
</div>
|
||||
<nav class="flex gap-1 ml-2">
|
||||
{#each nav as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
|
||||
item.href
|
||||
? 'bg-green-700 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||
class="btn btn-sm {$page.url.pathname === item.href ? 'btn-primary' : 'btn-ghost'}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
@@ -103,7 +104,7 @@
|
||||
</nav>
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="ml-auto text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
class="btn btn-xs btn-outline rounded-full ml-auto"
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{isDark ? 'Light' : 'Dark'}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fetchLogs, fetchStatus, invalidateCache } from '$lib/api';
|
||||
import type { LogEntry, StatusResponse } from '$lib/types';
|
||||
import { fetchLogs, fetchStatus, fetchTools, invalidateCache } from '$lib/api';
|
||||
import type { LogEntry, StatusResponse, ToolInfo } from '$lib/types';
|
||||
|
||||
let status = $state<StatusResponse | null>(null);
|
||||
let tools = $state<ToolInfo[]>([]);
|
||||
let logs = $state<LogEntry[]>([]);
|
||||
let loadError = $state<string | null>(null);
|
||||
let invalidating = $state(false);
|
||||
@@ -48,6 +49,10 @@
|
||||
onMount(() => {
|
||||
loadStatus();
|
||||
loadLogs();
|
||||
// Tool catalogue is static for the process lifetime — fetch once.
|
||||
fetchTools()
|
||||
.then((r) => (tools = r.tools))
|
||||
.catch(() => {});
|
||||
statusTimer = setInterval(loadStatus, 30_000);
|
||||
logTimer = setInterval(loadLogs, 5_000);
|
||||
});
|
||||
@@ -57,10 +62,6 @@
|
||||
clearInterval(logTimer);
|
||||
});
|
||||
|
||||
function dot(connected: boolean | undefined) {
|
||||
return connected ? 'bg-green-500 shadow-green-500/50 shadow-sm' : 'bg-red-500';
|
||||
}
|
||||
|
||||
function fmtMs(ms: number | undefined | null) {
|
||||
if (ms == null) return '—';
|
||||
return `${ms.toFixed(0)} ms`;
|
||||
@@ -78,232 +79,237 @@
|
||||
const pairs = Object.entries(args).map(([k, v]) => `${k}=${JSON.stringify(v)}`);
|
||||
return pairs.join(', ') || '—';
|
||||
}
|
||||
|
||||
function firstLine(text: string) {
|
||||
return text.split('\n').map((l) => l.trim()).find((l) => l) ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Title row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">System Status</h1>
|
||||
<h1 class="text-lg font-semibold">System Status</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if invalidateMsg}
|
||||
<span class="text-sm text-green-600 dark:text-green-400 transition-opacity">{invalidateMsg}</span>
|
||||
<span class="text-sm text-primary transition-opacity">{invalidateMsg}</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={doInvalidate}
|
||||
disabled={invalidating}
|
||||
class="px-3 py-1.5 rounded bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-300
|
||||
border border-gray-300 dark:border-gray-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<button onclick={doInvalidate} disabled={invalidating} class="btn btn-sm btn-outline">
|
||||
{invalidating ? 'Clearing…' : 'Clear Cache'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<div class="p-4 rounded-lg bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-300 text-sm">
|
||||
{loadError}
|
||||
</div>
|
||||
<div class="alert alert-error text-sm">{loadError}</div>
|
||||
{/if}
|
||||
|
||||
{#if status}
|
||||
<!-- Status cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<!-- Database -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||
<!-- Cache (PostgreSQL) -->
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4 gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.database.connected)}"></span>
|
||||
<span class="font-medium text-sm text-gray-900 dark:text-white">Database</span>
|
||||
<span class="status {status.database.connected ? 'status-success' : 'status-error'}"></span>
|
||||
<span class="font-medium text-sm">Cache (PostgreSQL)</span>
|
||||
</div>
|
||||
<dl class="text-sm space-y-1.5">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Host</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200">{status.database.host ?? '—'}</dd>
|
||||
<dt class="text-base-content/60">Host</dt>
|
||||
<dd>{status.database.host ?? '—'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Latency</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200">{fmtMs(status.database.latency_ms)}</dd>
|
||||
<dt class="text-base-content/60">Latency</dt>
|
||||
<dd>{fmtMs(status.database.latency_ms)}</dd>
|
||||
</div>
|
||||
{#if status.database.version}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Version</dt>
|
||||
<dd class="text-gray-500 dark:text-gray-400 text-xs font-mono truncate max-w-40">
|
||||
<dt class="text-base-content/60">Version</dt>
|
||||
<dd class="text-base-content/60 text-xs font-mono truncate max-w-40">
|
||||
{status.database.version}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if status.database.error}
|
||||
<div class="text-red-500 dark:text-red-400 text-xs pt-1">{status.database.error}</div>
|
||||
<div class="text-error text-xs pt-1">{status.database.error}</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TheSportsDB -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4 gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.api.connected)}"></span>
|
||||
<span class="font-medium text-sm text-gray-900 dark:text-white">TheSportsDB</span>
|
||||
<span class="status {status.api.connected ? 'status-success' : 'status-error'}"></span>
|
||||
<span class="font-medium text-sm">TheSportsDB</span>
|
||||
</div>
|
||||
<dl class="text-sm space-y-1.5">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Latency</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200">{fmtMs(status.api.latency_ms)}</dd>
|
||||
<dt class="text-base-content/60">Latency</dt>
|
||||
<dd>{fmtMs(status.api.latency_ms)}</dd>
|
||||
</div>
|
||||
{#if status.api.backend}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Backend</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200">{status.api.backend}</dd>
|
||||
<dt class="text-base-content/60">Backend</dt>
|
||||
<dd>{status.api.backend}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if status.api.error}
|
||||
<div class="text-red-500 dark:text-red-400 text-xs pt-1">{status.api.error}</div>
|
||||
<div class="text-error text-xs pt-1">{status.api.error}</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Server -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4 gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.mcp.running)}"></span>
|
||||
<span class="font-medium text-sm text-gray-900 dark:text-white">MCP Server</span>
|
||||
<span class="status {status.mcp.running ? 'status-success' : 'status-error'}"></span>
|
||||
<span class="font-medium text-sm">MCP Server</span>
|
||||
{#if status.mcp.premium}
|
||||
<span
|
||||
class="ml-auto text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800"
|
||||
>
|
||||
Premium
|
||||
</span>
|
||||
<span class="badge badge-warning badge-sm ml-auto">Premium</span>
|
||||
{/if}
|
||||
</div>
|
||||
<dl class="text-sm space-y-1.5">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Transport</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200">{status.mcp.transport}</dd>
|
||||
<dt class="text-base-content/60">Transport</dt>
|
||||
<dd>{status.mcp.transport}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Uptime</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200">{status.mcp.uptime}</dd>
|
||||
<dt class="text-base-content/60">Uptime</dt>
|
||||
<dd>{status.mcp.uptime}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Tools</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200">{status.mcp.tool_count}</dd>
|
||||
<dt class="text-base-content/60">Tools</dt>
|
||||
<dd>{status.mcp.tool_count}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-gray-500 shrink-0">Endpoint</dt>
|
||||
<dd class="text-gray-500 dark:text-gray-400 text-xs font-mono truncate" title={status.mcp.endpoint}>
|
||||
<dt class="text-base-content/60 shrink-0">Endpoint</dt>
|
||||
<dd class="text-base-content/60 text-xs font-mono truncate" title={status.mcp.endpoint}>
|
||||
{status.mcp.endpoint}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Followed teams + MCP tools -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Followed teams -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
|
||||
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider mb-1">
|
||||
Followed Teams
|
||||
</h2>
|
||||
{#if status.data.followed.length === 0}
|
||||
<p class="text-gray-500 text-sm">No teams configured (set NIKE_TEAMS in .env)</p>
|
||||
<p class="text-base-content/60 text-sm">No teams configured (set NIKE_TEAMS in .env)</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each status.data.followed as team}
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
<span class="text-green-500 text-xs">⚽</span>
|
||||
<span class="text-gray-900 dark:text-white">{team.team}</span>
|
||||
<span class="text-gray-300 dark:text-gray-700">·</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{team.league}</span>
|
||||
<span class="text-primary text-xs">⚽</span>
|
||||
<span>{team.team}</span>
|
||||
<span class="text-base-content/30">·</span>
|
||||
<span class="text-base-content/60">{team.league}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if status.data.last_cache}
|
||||
<p class="mt-3 text-xs text-gray-500">
|
||||
<p class="mt-3 text-xs text-base-content/60">
|
||||
Last cache update: {relTime(status.data.last_cache)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Tools -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
|
||||
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">MCP Tools</h2>
|
||||
<table class="w-full text-sm">
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-800/60">
|
||||
{#each status.tools as tool}
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider mb-1">MCP Tools</h2>
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
{#each tools as tool}
|
||||
<tr>
|
||||
<td class="py-1.5 pr-3">
|
||||
<code class="text-green-600 dark:text-green-300 text-xs">{tool.name}</code>
|
||||
<td class="align-top">
|
||||
<code class="text-primary text-xs">{tool.name}</code>
|
||||
{#if tool.premium}
|
||||
<span
|
||||
class="ml-1.5 text-xs px-1 rounded bg-amber-100 dark:bg-amber-900/50 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-800/50"
|
||||
title="Requires premium TheSportsDB key"
|
||||
class="badge badge-warning badge-xs ml-1 tooltip"
|
||||
data-tip="Requires a premium TheSportsDB key"
|
||||
>
|
||||
★
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-1.5 text-gray-500 dark:text-gray-400">{tool.description}</td>
|
||||
<td class="text-base-content/60">{firstLine(tool.description)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DB table counts -->
|
||||
{#if Object.keys(status.data.table_counts).length > 0}
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
|
||||
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
Database Contents
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider mb-1">
|
||||
Cache Contents
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each Object.entries(status.data.table_counts) as [table, count]}
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">{table}</div>
|
||||
<div class="text-gray-900 dark:text-white font-mono text-sm mt-0.5">{count.toLocaleString()}</div>
|
||||
<div class="stat p-0">
|
||||
<div class="stat-title text-xs">{table}</div>
|
||||
<div class="stat-value text-base font-mono">{count.toLocaleString()}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if !loadError}
|
||||
<div class="text-gray-500 text-sm animate-pulse">Loading status…</div>
|
||||
<div class="flex items-center gap-2 text-base-content/60 text-sm">
|
||||
<span class="loading loading-spinner loading-sm"></span> Loading status…
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Request Log -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||
<div
|
||||
class="px-4 py-3 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between"
|
||||
>
|
||||
<h2 class="text-sm font-medium text-gray-900 dark:text-white">Request Log</h2>
|
||||
<span class="text-xs text-gray-500">{logs.length} entries · auto-refreshes every 5 s</span>
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="px-4 py-3 border-b border-base-300 flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium">Request Log</h2>
|
||||
<span class="text-xs text-base-content/60">{logs.length} entries · auto-refreshes every 5 s</span>
|
||||
</div>
|
||||
{#if logs.length === 0}
|
||||
<p class="px-4 py-5 text-gray-500 text-sm">No MCP requests yet.</p>
|
||||
<p class="px-4 py-5 text-base-content/60 text-sm">No MCP requests yet.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<table class="table table-zebra table-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500 dark:text-gray-600 border-b border-gray-200 dark:border-gray-800">
|
||||
<th class="px-4 py-2 font-medium">Time</th>
|
||||
<th class="px-4 py-2 font-medium">Tool</th>
|
||||
<th class="px-4 py-2 font-medium">Args</th>
|
||||
<th class="px-4 py-2 font-medium text-right">Duration</th>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Tool</th>
|
||||
<th>Args</th>
|
||||
<th class="text-right">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100/40 dark:divide-gray-800/40">
|
||||
<tbody>
|
||||
{#each logs as entry}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors">
|
||||
<td class="px-4 py-2 text-gray-500 whitespace-nowrap text-xs">
|
||||
<tr>
|
||||
<td class="text-base-content/60 whitespace-nowrap text-xs">
|
||||
{relTime(entry.timestamp)}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<code class="text-green-600 dark:text-green-300 text-xs">{entry.tool}</code>
|
||||
<td>
|
||||
<code class="text-primary text-xs">{entry.tool}</code>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-gray-500 dark:text-gray-400 text-xs font-mono max-w-xs truncate">
|
||||
<td class="text-base-content/60 text-xs font-mono max-w-xs truncate">
|
||||
{fmtArgs(entry.args)}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-gray-500 whitespace-nowrap text-xs">
|
||||
<td class="text-right text-base-content/60 whitespace-nowrap text-xs">
|
||||
{entry.duration_ms} ms
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,98 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { createTooltip, melt } from '@melt-ui/svelte';
|
||||
import { runTool } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchTools, runTool } from '$lib/api';
|
||||
import type { ToolInfo, ToolParam } from '$lib/types';
|
||||
|
||||
// ── Tool definitions ────────────────────────────────────
|
||||
|
||||
type ParamType = 'text' | 'number' | 'date' | 'select';
|
||||
|
||||
interface Param {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ParamType;
|
||||
default: string;
|
||||
// ── Frontend-only form niceties, keyed by backend param name ──
|
||||
// The backend tool schema carries name/type/default/required; labels,
|
||||
// placeholders, select options, and the input widget live here so the
|
||||
// API stays presentation-free.
|
||||
interface ParamUi {
|
||||
label?: string;
|
||||
input?: 'date' | 'select';
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
fallbackDefault?: string;
|
||||
}
|
||||
const PARAM_UI: Record<string, ParamUi> = {
|
||||
team_name: { label: 'Team Name', placeholder: 'e.g. Arsenal, Toronto FC', fallbackDefault: 'Toronto FC' },
|
||||
player_name: { label: 'Player Name', placeholder: 'e.g. Federico Bernardeschi' },
|
||||
status: { label: 'Status Filter', input: 'select', options: ['all', 'upcoming', 'past'] },
|
||||
match_date: { label: 'Date', input: 'date' },
|
||||
event_id: { label: 'Event ID', placeholder: 'Get from get_fixtures first' },
|
||||
league: { label: 'League', placeholder: 'e.g. English Premier League' },
|
||||
season: { label: 'Season', placeholder: 'e.g. 2026 or 2025-2026' },
|
||||
};
|
||||
|
||||
interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
premium: boolean;
|
||||
params: Param[];
|
||||
function uiFor(p: ToolParam): ParamUi {
|
||||
return PARAM_UI[p.name] ?? {};
|
||||
}
|
||||
function labelFor(p: ToolParam) {
|
||||
return uiFor(p).label ?? p.name;
|
||||
}
|
||||
function inputType(p: ToolParam): 'text' | 'number' | 'date' {
|
||||
const ui = uiFor(p);
|
||||
if (ui.input === 'date') return 'date';
|
||||
return p.type === 'integer' ? 'number' : 'text';
|
||||
}
|
||||
function initialValue(p: ToolParam): string {
|
||||
if (p.default != null) return String(p.default);
|
||||
return uiFor(p).fallbackDefault ?? '';
|
||||
}
|
||||
|
||||
const TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: 'get_team_info',
|
||||
description: 'Team profile: stadium, capacity, location, founded year, colors.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC', placeholder: 'e.g. Arsenal, Toronto FC' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_roster',
|
||||
description: 'Current squad grouped by position. Requires premium key for live data.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_player_info',
|
||||
description: 'Player profile: position, nationality, DOB, team, status.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'player_name', label: 'Player Name', type: 'text', default: '', placeholder: 'e.g. Federico Bernardeschi' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_fixtures',
|
||||
description: 'Recent results and upcoming matches for a team.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
|
||||
{ key: 'status', label: 'Status Filter', type: 'select', default: 'all', options: ['all', 'upcoming', 'past'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_standings',
|
||||
description: 'Full league table with points, goal difference, and form.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'league', label: 'League', type: 'text', default: 'American Major League Soccer', placeholder: 'e.g. English Premier League' },
|
||||
{ key: 'season', label: 'Season', type: 'text', default: '2026', placeholder: 'e.g. 2026 or 2025-2026' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_match_result',
|
||||
description: 'Match result for a team on a specific date.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
|
||||
{ key: 'match_date', label: 'Date', type: 'date', default: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_match_detail',
|
||||
description: 'Deep match stats, lineup, and timeline. Requires a premium TheSportsDB key.',
|
||||
premium: true,
|
||||
params: [
|
||||
{ key: 'event_id', label: 'Event ID', type: 'number', default: '', placeholder: 'Get from get_fixtures first' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_livescores',
|
||||
description: 'Current live soccer scores worldwide. Requires a premium TheSportsDB key.',
|
||||
premium: true,
|
||||
params: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ── State ────────────────────────────────────────────────
|
||||
|
||||
let selectedTool = $state<ToolDef>(TOOLS[0]);
|
||||
let tools = $state<ToolInfo[]>([]);
|
||||
let selectedTool = $state<ToolInfo | null>(null);
|
||||
let paramValues = $state<Record<string, string>>({});
|
||||
let running = $state(false);
|
||||
let result = $state<string | null>(null);
|
||||
@@ -107,26 +58,30 @@
|
||||
}
|
||||
let history = $state<HistoryEntry[]>([]);
|
||||
|
||||
function selectTool(tool: ToolDef) {
|
||||
function selectTool(tool: ToolInfo) {
|
||||
selectedTool = tool;
|
||||
result = null;
|
||||
resultError = null;
|
||||
paramValues = Object.fromEntries(tool.params.map((p) => [p.key, p.default]));
|
||||
paramValues = Object.fromEntries(tool.params.map((p) => [p.name, initialValue(p)]));
|
||||
}
|
||||
|
||||
// Init
|
||||
selectTool(TOOLS[0]);
|
||||
onMount(async () => {
|
||||
const r = await fetchTools();
|
||||
tools = r.tools;
|
||||
if (tools.length > 0) selectTool(tools[0]);
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!selectedTool) return;
|
||||
running = true;
|
||||
result = null;
|
||||
resultError = null;
|
||||
|
||||
const args: Record<string, unknown> = {};
|
||||
for (const p of selectedTool.params) {
|
||||
const val = paramValues[p.key];
|
||||
const val = paramValues[p.name];
|
||||
if (val === '') continue;
|
||||
args[p.key] = p.type === 'number' ? Number(val) : val;
|
||||
args[p.name] = p.type === 'integer' ? Number(val) : val;
|
||||
}
|
||||
|
||||
const snapshot = { ...paramValues };
|
||||
@@ -149,7 +104,7 @@
|
||||
}
|
||||
|
||||
function loadHistory(entry: HistoryEntry) {
|
||||
const tool = TOOLS.find((t) => t.name === entry.tool);
|
||||
const tool = tools.find((t) => t.name === entry.tool);
|
||||
if (!tool) return;
|
||||
selectTool(tool);
|
||||
// selectTool resets paramValues, restore after microtask
|
||||
@@ -168,138 +123,124 @@
|
||||
function fmtTime(d: Date) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
// Melt UI tooltip for premium badge
|
||||
const {
|
||||
elements: { trigger: premTrigger, content: premContent },
|
||||
states: { open: premOpen },
|
||||
} = createTooltip({ positioning: { placement: 'top' }, openDelay: 200 });
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Tool Runner</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
<h1 class="text-lg font-semibold">Tool Runner</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Run MCP tools interactively and inspect raw API responses. Useful for spotting strange API
|
||||
results.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !selectedTool}
|
||||
<div class="flex items-center gap-2 text-base-content/60 text-sm">
|
||||
<span class="loading loading-spinner loading-sm"></span> Loading tools…
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
|
||||
<!-- Left: selector + form + result -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<!-- Tool selector -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
|
||||
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Select Tool</h2>
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider mb-1">Select Tool</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each TOOLS as tool}
|
||||
{#each tools as tool}
|
||||
<button
|
||||
onclick={() => selectTool(tool)}
|
||||
class="px-3 py-1.5 rounded text-sm font-medium transition-colors
|
||||
{selectedTool.name === tool.name
|
||||
? 'bg-green-700 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700'}"
|
||||
class="btn btn-sm {selectedTool.name === tool.name ? 'btn-primary' : 'btn-soft'}"
|
||||
>
|
||||
{tool.name}
|
||||
{#if tool.premium}
|
||||
<span use:melt={$premTrigger} class="ml-1 text-amber-500 dark:text-amber-400 cursor-help">★</span>
|
||||
<span
|
||||
class="tooltip cursor-help text-warning"
|
||||
data-tip="Requires a premium TheSportsDB key"
|
||||
>
|
||||
★
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameter form -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-4">
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white font-mono">{selectedTool.name}</h2>
|
||||
<h2 class="text-sm font-semibold font-mono">{selectedTool.name}</h2>
|
||||
{#if selectedTool.premium}
|
||||
<span
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800/50"
|
||||
>
|
||||
Premium
|
||||
</span>
|
||||
<span class="badge badge-warning badge-sm">Premium</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{selectedTool.description}</p>
|
||||
<p class="text-xs text-base-content/60 mt-1 whitespace-pre-line">{selectedTool.description}</p>
|
||||
</div>
|
||||
|
||||
{#if selectedTool.params.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each selectedTool.params as param}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5" for={param.key}>
|
||||
{param.label}
|
||||
<label class="label text-xs font-medium mb-1" for={param.name}>
|
||||
{labelFor(param)}
|
||||
</label>
|
||||
{#if param.type === 'select'}
|
||||
{#if uiFor(param).input === 'select'}
|
||||
<select
|
||||
id={param.key}
|
||||
bind:value={paramValues[param.key]}
|
||||
class="w-full rounded-md bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-2 text-sm
|
||||
text-gray-900 dark:text-gray-100 focus:outline-none focus:border-green-600 focus:ring-1
|
||||
focus:ring-green-600"
|
||||
id={param.name}
|
||||
bind:value={paramValues[param.name]}
|
||||
class="select select-sm w-full"
|
||||
>
|
||||
{#each param.options ?? [] as opt}
|
||||
{#each uiFor(param).options ?? [] as opt}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
id={param.key}
|
||||
type={param.type === 'number' ? 'number' : param.type === 'date' ? 'date' : 'text'}
|
||||
placeholder={param.placeholder ?? ''}
|
||||
bind:value={paramValues[param.key]}
|
||||
class="w-full rounded-md bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-2 text-sm
|
||||
text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:border-green-600
|
||||
focus:ring-1 focus:ring-green-600"
|
||||
id={param.name}
|
||||
type={inputType(param)}
|
||||
placeholder={uiFor(param).placeholder ?? ''}
|
||||
bind:value={paramValues[param.name]}
|
||||
class="input input-sm w-full"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-gray-500 italic">No parameters — just click Run.</p>
|
||||
<p class="text-xs text-base-content/60 italic">No parameters — just click Run.</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={submit}
|
||||
disabled={running}
|
||||
class="px-4 py-2 rounded-md bg-green-700 hover:bg-green-600 text-white text-sm
|
||||
font-medium disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<button onclick={submit} disabled={running} class="btn btn-primary btn-sm self-start">
|
||||
{#if running}
|
||||
<span class="inline-block w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
Running…
|
||||
{:else}
|
||||
Run Tool
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
{#if result !== null || resultError !== null}
|
||||
<div class="card bg-base-100 border {resultError ? 'border-error' : 'border-base-300'}">
|
||||
<div
|
||||
class="rounded-lg bg-white dark:bg-gray-900 border {resultError
|
||||
? 'border-red-200 dark:border-red-800'
|
||||
: 'border-gray-200 dark:border-gray-800'}"
|
||||
class="px-4 py-2.5 border-b {resultError ? 'border-error' : 'border-base-300'} flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-2.5 border-b {resultError
|
||||
? 'border-red-200 dark:border-red-800'
|
||||
: 'border-gray-200 dark:border-gray-800'} flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xs font-medium {resultError ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
|
||||
<span class="text-xs font-medium {resultError ? 'text-error' : 'text-success'}">
|
||||
{resultError ? 'Error' : 'Result'}
|
||||
</span>
|
||||
{#if result}
|
||||
<span class="text-xs text-gray-500">
|
||||
<span class="text-xs text-base-content/60">
|
||||
{result.split('\n').length} lines
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<pre
|
||||
class="px-4 py-4 text-sm font-mono whitespace-pre-wrap text-gray-700 dark:text-gray-200 overflow-x-auto
|
||||
class="px-4 py-4 text-sm font-mono whitespace-pre-wrap overflow-x-auto
|
||||
max-h-[520px] overflow-y-auto leading-relaxed"
|
||||
>{resultError ?? result}</pre>
|
||||
</div>
|
||||
@@ -307,35 +248,35 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: session history -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 sticky top-20">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider">Session History</h2>
|
||||
<div class="card bg-base-100 border border-base-300 sticky top-20">
|
||||
<div class="px-4 py-3 border-b border-base-300">
|
||||
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider">Session History</h2>
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<p class="px-4 py-5 text-sm text-gray-500">No queries yet.</p>
|
||||
<p class="px-4 py-5 text-sm text-base-content/60">No queries yet.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800 max-h-[600px] overflow-y-auto">
|
||||
<ul class="divide-y divide-base-300 max-h-[600px] overflow-y-auto">
|
||||
{#each history as entry}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => loadHistory(entry)}
|
||||
class="w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<code class="text-xs {entry.ok ? 'text-green-600 dark:text-green-300' : 'text-red-500 dark:text-red-400'} truncate">
|
||||
<code class="text-xs {entry.ok ? 'text-primary' : 'text-error'} truncate">
|
||||
{entry.tool}
|
||||
</code>
|
||||
<span class="text-xs text-gray-500 shrink-0">{fmtTime(entry.ts)}</span>
|
||||
<span class="text-xs text-base-content/60 shrink-0">{fmtTime(entry.ts)}</span>
|
||||
</div>
|
||||
{#each Object.entries(entry.args) as [k, v]}
|
||||
{#if v}
|
||||
<div class="text-xs text-gray-500 truncate mt-0.5">
|
||||
{k}: <span class="text-gray-500 dark:text-gray-400">{v}</span>
|
||||
<div class="text-xs text-base-content/60 truncate mt-0.5">
|
||||
{k}: <span class="text-base-content/80">{v}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if !entry.ok}
|
||||
<div class="text-xs text-red-500 mt-0.5 truncate">{entry.output}</div>
|
||||
<div class="text-xs text-error mt-0.5 truncate">{entry.output}</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
@@ -344,14 +285,5 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Melt UI tooltip for premium star -->
|
||||
{#if $premOpen}
|
||||
<div
|
||||
use:melt={$premContent}
|
||||
class="z-50 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2.5 py-1.5 text-xs text-amber-700 dark:text-amber-300 shadow-lg"
|
||||
>
|
||||
Requires a premium TheSportsDB key
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"status": "success",
|
||||
"response": {
|
||||
"matches": []
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Request Failed Please try Again"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -28,10 +28,6 @@ FOLLOWED_TEAMS = [
|
||||
if ':' in pair
|
||||
]
|
||||
|
||||
# ── Legacy API keys (preserved, not active) ───────────────
|
||||
RAPIDAPI_KEY = os.getenv('NIKE_RAPIDAPI_KEY', '')
|
||||
API_FOOTBALL_KEY = os.getenv('NIKE_API_FOOTBALL_KEY', '')
|
||||
|
||||
# ── Server ────────────────────────────────────────────────
|
||||
SERVER_HOST = os.getenv('NIKE_HOST')
|
||||
SERVER_PORT = int(os.getenv('NIKE_PORT'))
|
||||
|
||||
356
nike/rapidapi.py
356
nike/rapidapi.py
@@ -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})
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
Nike MCP Server + Bootstrap Dashboard
|
||||
======================================
|
||||
Nike MCP Server + Dashboard
|
||||
===========================
|
||||
Single process on 0.0.0.0:8000
|
||||
|
||||
/ → Bootstrap status dashboard
|
||||
/ → SvelteKit dashboard (dashboard/build)
|
||||
/api/* → Dashboard JSON API
|
||||
/mcp → FastMCP HTTP endpoint (streamable-HTTP)
|
||||
|
||||
@@ -28,7 +28,6 @@ import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from prometheus_client import (
|
||||
CONTENT_TYPE_LATEST,
|
||||
CollectorRegistry,
|
||||
@@ -539,7 +538,7 @@ def get_match_result(team_name: str, match_date: str) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
||||
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True), tags={"premium"})
|
||||
def get_match_detail(event_id: int) -> str:
|
||||
"""
|
||||
Get deep match detail: stats, lineup, and timeline for a specific match.
|
||||
@@ -651,7 +650,7 @@ def get_match_detail(event_id: int) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
||||
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True), tags={"premium"})
|
||||
def get_livescores() -> str:
|
||||
"""
|
||||
Get current live soccer scores worldwide.
|
||||
@@ -731,9 +730,6 @@ def football_analyst() -> str:
|
||||
_mcp_app = mcp.http_app(path="/")
|
||||
|
||||
# ── FastAPI dashboard app ─────────────────────────────────
|
||||
_TEMPLATES = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent / "templates")
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -781,11 +777,15 @@ _SVELTE_BUILD = Path(__file__).parent.parent / "dashboard" / "build"
|
||||
|
||||
|
||||
@dashboard.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
async def index():
|
||||
svelte_index = _SVELTE_BUILD / "index.html"
|
||||
if svelte_index.exists():
|
||||
return FileResponse(str(svelte_index))
|
||||
return _TEMPLATES.TemplateResponse(request, "dashboard.html")
|
||||
return HTMLResponse(
|
||||
"<h1>Nike</h1><p>Dashboard not built. "
|
||||
"Run: <code>cd dashboard && npm install && npm run build</code></p>",
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
|
||||
@dashboard.get("/api/status")
|
||||
@@ -797,19 +797,8 @@ async def api_status():
|
||||
uptime_s = int((datetime.now(timezone.utc) - _SERVER_START).total_seconds())
|
||||
uptime_str = f"{uptime_s // 3600}h {(uptime_s % 3600) // 60}m {uptime_s % 60}s"
|
||||
is_premium = config.SPORTSDB_KEY not in ('3', '')
|
||||
tool_count = len(await mcp.list_tools())
|
||||
|
||||
tools = [
|
||||
{"name": "get_team_info", "description": "Team profile", "readonly": True},
|
||||
{"name": "get_roster", "description": "Squad by position", "readonly": True},
|
||||
{"name": "get_player_info", "description": "Player profile", "readonly": True},
|
||||
{"name": "get_fixtures", "description": "Results & upcoming", "readonly": True},
|
||||
{"name": "get_standings", "description": "League table", "readonly": True},
|
||||
{"name": "get_match_result", "description": "Match on a date", "readonly": True},
|
||||
{"name": "get_match_detail", "description": "Stats/lineup/timeline", "readonly": True,
|
||||
"premium": True},
|
||||
{"name": "get_livescores", "description": "Live scores", "readonly": True,
|
||||
"premium": True},
|
||||
]
|
||||
db_status.setdefault("host", config.DB_HOST)
|
||||
return JSONResponse({
|
||||
"database": db_status,
|
||||
@@ -820,7 +809,7 @@ async def api_status():
|
||||
"endpoint": f"http://{config.SERVER_HOST}:{config.SERVER_PORT}/mcp",
|
||||
"port": config.SERVER_PORT,
|
||||
"uptime": uptime_str,
|
||||
"tool_count": len(tools),
|
||||
"tool_count": tool_count,
|
||||
"premium": is_premium,
|
||||
},
|
||||
"data": {
|
||||
@@ -829,10 +818,31 @@ async def api_status():
|
||||
"followed": [{"team": t[0], "league": t[1]}
|
||||
for t in config.FOLLOWED_TEAMS],
|
||||
},
|
||||
"tools": tools,
|
||||
})
|
||||
|
||||
|
||||
@dashboard.get("/api/tools")
|
||||
async def api_tools():
|
||||
"""Tool catalogue derived from the registered MCP tools (single source)."""
|
||||
out = []
|
||||
for t in await mcp.list_tools():
|
||||
schema = t.parameters or {}
|
||||
required = set(schema.get("required", []))
|
||||
out.append({
|
||||
"name": t.name,
|
||||
"description": t.description or "",
|
||||
"premium": "premium" in t.tags,
|
||||
"params": [
|
||||
{"name": name,
|
||||
"type": spec.get("type", "string"),
|
||||
"default": spec.get("default"),
|
||||
"required": name in required}
|
||||
for name, spec in schema.get("properties", {}).items()
|
||||
],
|
||||
})
|
||||
return JSONResponse({"tools": out})
|
||||
|
||||
|
||||
@dashboard.get("/api/logs")
|
||||
async def api_logs(limit: int = 50):
|
||||
return JSONResponse({"logs": list(_REQUEST_LOG)[:limit]})
|
||||
@@ -850,41 +860,8 @@ async def api_cache_invalidate():
|
||||
})
|
||||
|
||||
|
||||
@dashboard.post("/api/sync")
|
||||
async def api_sync():
|
||||
"""Cache refresh — replaces the legacy API-Football DB sync."""
|
||||
start = time.perf_counter()
|
||||
db.invalidate_cache("%")
|
||||
api.clear_cache()
|
||||
duration = round(time.perf_counter() - start, 2)
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"result": {"players": 0, "seasons": {}},
|
||||
"duration_s": duration,
|
||||
"note": "Cache cleared; fresh data will be fetched on next request.",
|
||||
})
|
||||
|
||||
|
||||
# ── Tool runner API ───────────────────────────────────────
|
||||
|
||||
_TOOLS: dict[str, Any] = {} # populated after tool definitions exist
|
||||
|
||||
|
||||
def _register_tools() -> None:
|
||||
_TOOLS.update({
|
||||
"get_team_info": get_team_info,
|
||||
"get_roster": get_roster,
|
||||
"get_player_info": get_player_info,
|
||||
"get_fixtures": get_fixtures,
|
||||
"get_standings": get_standings,
|
||||
"get_match_result": get_match_result,
|
||||
"get_match_detail": get_match_detail,
|
||||
"get_livescores": get_livescores,
|
||||
})
|
||||
|
||||
|
||||
_register_tools()
|
||||
|
||||
|
||||
class _RunRequest(BaseModel):
|
||||
tool: str
|
||||
@@ -908,11 +885,11 @@ class _TelemetryReport(BaseModel):
|
||||
|
||||
@dashboard.post("/api/run")
|
||||
async def api_run(body: _RunRequest):
|
||||
fn = _TOOLS.get(body.tool)
|
||||
if fn is None:
|
||||
tool = await mcp.get_tool(body.tool)
|
||||
if tool is None:
|
||||
return JSONResponse({"ok": False, "error": f"Unknown tool: {body.tool!r}"}, status_code=400)
|
||||
try:
|
||||
result = fn(**body.args)
|
||||
result = tool.fn(**body.args)
|
||||
return JSONResponse({"ok": True, "result": result})
|
||||
except Exception as exc:
|
||||
logger.error("Tool run failed: tool=%s args=%s", body.tool, body.args, exc_info=True)
|
||||
|
||||
250
nike/sync.py
250
nike/sync.py
@@ -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}
|
||||
@@ -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 & 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>
|
||||
@@ -11,11 +11,10 @@ requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"fastmcp>=2.0",
|
||||
"fastmcp>=3",
|
||||
"psycopg2-binary>=2.9",
|
||||
"python-dotenv>=1.0",
|
||||
"requests>=2.32",
|
||||
"jinja2>=3.1",
|
||||
"wsproto>=1.2",
|
||||
"prometheus-client>=0.20",
|
||||
]
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply Nike schema to Portia PostgreSQL."""
|
||||
"""Apply the Nike cache schema (schema.sql) to PostgreSQL."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
import psycopg2
|
||||
|
||||
load_dotenv('/home/robert/gitea/nike/.env')
|
||||
from nike import config
|
||||
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=os.getenv('DB_HOST'),
|
||||
port=int(os.getenv('DB_PORT', 5432)),
|
||||
user=os.getenv('DB_USER'),
|
||||
password=os.getenv('DB_PASSWORD'),
|
||||
dbname='nike',
|
||||
host=config.DB_HOST,
|
||||
port=config.DB_PORT,
|
||||
user=config.DB_USER,
|
||||
password=config.DB_PASSWORD,
|
||||
dbname=config.DB_NAME,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Cannot connect to DB: {e}")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user