Files
football-api/api/main.py
rgcosta 4509850a29
All checks were successful
Build & Push Football Docker Images / build-push-update (push) Successful in 7s
Update api/main.py
Clean up
2026-04-12 15:22:32 +00:00

252 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
GET /widget — HTML widget for Homepage iframe
"""
import json
import re
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():
try:
d = load()
past, future = sorted_matches(d)
except HTTPException:
past, future = [], []
# Most recent 3 past (newest leftmost = reversed) + next 2 future
last3 = list(reversed(past[-3:])) if len(past) >= 3 else list(reversed(past))
next2 = future[:2] if len(future) >= 2 else future
empty_past = {"home": "", "away": "", "home_logo": None, "away_logo": None,
"abbr": "", "date": "", "time": "", "score": None}
empty_future = {**empty_past, "score": None}
while len(last3) < 3:
last3.append(dict(empty_past))
while len(next2) < 2:
next2.append(dict(empty_future))
PORTO_LOGO = "https://a.espncdn.com/i/teamlogos/soccer/500/437.png"
def clean_time(t: str) -> str:
"""Remove ESPN artifacts like 'v', 'v2nd Leg' etc from time field."""
t = t.strip()
# If it looks like a time (contains :) keep it, otherwise TBD
if re.search(r'\d+:\d+', t):
return re.search(r'\d+:\d+\s*(?:AM|PM)?', t).group(0).strip()
return "TBD"
def shorten(name: str) -> str:
if len(name) <= 10:
return name
parts = name.split()
return parts[-1] if parts else name[:10]
def card(m: dict, is_past: bool) -> str:
home_logo = m.get("home_logo") or PORTO_LOGO
away_logo = m.get("away_logo") or PORTO_LOGO
home_s = shorten(m.get("home", ""))
away_s = shorten(m.get("away", ""))
abbr = m.get("abbr", "")
score = m.get("score")
date = m.get("date", "")
time_raw = m.get("time", "")
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_clean = clean_time(time_raw)
time_part = f"<br>{time_clean}" if time_clean != "TBD" else ""
middle = f'<div class="fixture">{date_s}{time_part}</div>'
cls = "card future"
return f"""<div class="{cls}">
<img src="{home_logo}" class="badge" alt="">
<div class="name home-name">{home_s}</div>
{middle}
<img src="{away_logo}" class="badge" alt="">
<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;
}}
.container {{
display: flex;
align-items: center;
width: 100%;
padding: 0 4px;
}}
.section {{
display: flex;
align-items: stretch;
gap: 2px;
}}
.divider {{
width: 1px;
background: #334155;
margin: 4px 8px;
align-self: stretch;
flex-shrink: 0;
}}
.card {{
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2px 6px;
gap: 2px;
min-width: 60px;
}}
.past {{ opacity: 0.7; }}
.future {{ opacity: 1; }}
.badge {{
width: 24px;
height: 24px;
object-fit: contain;
}}
.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: 12px;
font-weight: 800;
color: #4ade80;
padding: 2px 0;
}}
.fixture {{
font-size: 9px;
color: #93c5fd;
text-align: center;
line-height: 1.4;
}}
.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>"""