Files
football-api/api/main.py
rgcosta 6baac48733
All checks were successful
Build & Push Football Docker Images / build-push-update (push) Successful in 7s
Update api/main.py
2026-04-12 14:10:18 +00:00

264 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""
FC Porto fixtures API
Endpoints:
GET /health — liveness
GET /data — raw JSON
GET /next — next match (for Homepage customapi)
GET /widget — self-contained HTML widget for Homepage iframe
"""
import json
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
DATA_FILE = Path("/data/porto.json")
PT_TZ = ZoneInfo("Europe/Lisbon")
app = FastAPI(title="FC Porto API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_headers=["*"],
)
def load() -> dict:
if not DATA_FILE.exists():
raise HTTPException(503, "Data not yet available — scraper hasn't run yet")
return json.loads(DATA_FILE.read_text())
def sorted_matches(d: dict):
"""
Split by score: has score = past, no score = future.
Combines all_past + all_future, deduplicates, filters year-rollover ghosts.
"""
now_ts = datetime.now(PT_TZ).timestamp()
max_future = now_ts + 60 * 60 * 24 * 180 # 6 months ahead
all_matches = []
for m in d.get("all_past", []) + d.get("all_future", []):
ts = m.get("timestamp", 0)
if ts > 0 and ts < max_future:
all_matches.append(m)
# Deduplicate by (home, away, date)
seen, unique = set(), []
for m in all_matches:
key = (m.get("home"), m.get("away"), m.get("date"))
if key not in seen:
seen.add(key)
unique.append(m)
unique.sort(key=lambda x: x["timestamp"])
past = [m for m in unique if m.get("score")]
future = [m for m in unique if not m.get("score")]
return past, future
@app.get("/health")
def health():
return {"status": "ok", "data_exists": DATA_FILE.exists()}
@app.get("/data")
def data():
return load()
@app.get("/next")
def next_match():
d = load()
_, future = sorted_matches(d)
if not future:
return {"opponent": "No upcoming matches", "date": "", "competition": "", "venue": ""}
m = future[0]
return {
"opponent": m["opponent"],
"date": f"{m['date']} {m['time']}",
"competition": m["competition"],
"venue": "Home" if m["is_home"] else "Away",
"home": m["home"],
"away": m["away"],
}
@app.get("/widget", response_class=HTMLResponse)
def widget():
"""
Layout: 3 past results + 2 upcoming fixtures
For each match card:
[ home badge ]
[ home name ]
[ score/date ]
[ away badge ]
[ away name ]
[ comp abbr ]
"""
try:
d = load()
past, future = sorted_matches(d)
except HTTPException:
past, future = [], []
# Last 3 past + next 2 future
last3 = past[-3:] if len(past) >= 3 else past
next2 = future[:2] if len(future) >= 2 else future
# Pad with empties
empty = {
"home": "", "away": "",
"home_logo": None, "away_logo": None,
"competition": "", "abbr": "",
"date": "", "time": "", "score": None, "is_past": True,
}
while len(last3) < 3:
last3.insert(0, dict(empty))
while len(next2) < 2:
next2.append(dict(empty, is_past=False))
all_cards = last3 + next2
PORTO_LOGO = "https://a.espncdn.com/i/teamlogos/soccer/500/437.png"
def card(m: dict, is_past: bool) -> str:
home = m.get("home", "")
away = m.get("away", "")
home_logo = m.get("home_logo") or PORTO_LOGO
away_logo = m.get("away_logo") or PORTO_LOGO
abbr = m.get("abbr", "")
score = m.get("score")
date = m.get("date", "")
time_ = m.get("time", "")
# Shorten names for display
def shorten(name: str) -> str:
if len(name) <= 10:
return name
parts = name.split()
return parts[-1] if parts else name[:10]
home_s = shorten(home)
away_s = shorten(away)
if is_past and score:
middle = f'<div class="score">{score}</div>'
cls = "card past"
else:
date_s = "/".join(date.split("/")[:2]) if "/" in date else date
time_s = f"<br>{time_}" if time_ and time_ != "TBD" else ""
middle = f'<div class="fixture">{date_s}{time_s}</div>'
cls = "card future"
return f"""
<div class="{cls}">
<img src="{home_logo}" class="badge" alt="{home}">
<div class="name home-name">{home_s}</div>
{middle}
<img src="{away_logo}" class="badge" alt="{away}">
<div class="name away-name">{away_s}</div>
<div class="comp">{abbr}</div>
</div>"""
past_cards = "".join(card(m, True) for m in last3)
future_cards = "".join(card(m, False) for m in next2)
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{
font-family: ui-sans-serif, system-ui, sans-serif;
background: transparent;
color: #e2e8f0;
height: 100vh;
display: flex;
align-items: center;
overflow: hidden;
}}
.section {{
display: flex;
align-items: stretch;
gap: 2px;
}}
.divider {{
width: 1px;
background: #334155;
margin: 4px 6px;
align-self: stretch;
flex-shrink: 0;
}}
.container {{
display: flex;
align-items: center;
width: 100%;
padding: 0 4px;
}}
.card {{
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2px 4px;
gap: 1px;
min-width: 0;
}}
.past {{ opacity: 0.7; }}
.future {{ opacity: 1; }}
.badge {{
width: 22px;
height: 22px;
object-fit: contain;
flex-shrink: 0;
}}
.name {{
font-size: 9px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-align: center;
}}
.home-name {{ color: #cbd5e1; }}
.away-name {{ color: #94a3b8; }}
.score {{
font-size: 11px;
font-weight: 800;
color: #4ade80;
padding: 1px 0;
}}
.fixture {{
font-size: 9px;
color: #93c5fd;
text-align: center;
line-height: 1.3;
}}
.comp {{
font-size: 8px;
color: #475569;
font-weight: 500;
}}
</style>
</head>
<body>
<div class="container">
<div class="section">{past_cards}</div>
<div class="divider"></div>
<div class="section">{future_cards}</div>
</div>
</body>
</html>"""