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

273 lines
6.9 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", "")
time_str = m.get("time", "")
time_str = re.sub(r'^v\s*', '', time_str).strip()
time_str = time_str if time_str not in ("", "TBD", "v") else "TBD"
# 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_part = f"<br>{time_str}" if time_str and time_str != "TBD" else ""
middle = f'<div class="fixture">{date_s}{time_part}</div>'
time_s = f"<br>{time_}" if time_ and time_ != "TBD" else ""
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>"""