All checks were successful
Build & Push Football Docker Images / build-push-update (push) Successful in 7s
256 lines
6.8 KiB
Python
256 lines
6.8 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 = past[-3:] if len(past) >= 3 else 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;
|
|
justify-content: center; /* center the whole thing */
|
|
width: 100%;
|
|
height: 100%; /* fill full iframe height */
|
|
padding: 0 20px;
|
|
}}
|
|
.section {{
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 8px;
|
|
flex: 1; /* each section takes equal space */
|
|
justify-content: space-around;
|
|
}}
|
|
.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: 4px 8px;
|
|
gap: 4px;
|
|
min-width: 80px;
|
|
}}
|
|
.past {{ opacity: 0.7; }}
|
|
.future {{ opacity: 1; }}
|
|
.badge {{
|
|
width: 36px;
|
|
height: 63px;
|
|
object-fit: contain;
|
|
}}
|
|
.name {{
|
|
font-size: 12px;
|
|
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: 14px;
|
|
font-weight: 800;
|
|
color: #4ade80;
|
|
padding: 2px 0;
|
|
}}
|
|
.fixture {{
|
|
font-size: 12px;
|
|
color: #93c5fd;
|
|
text-align: center;
|
|
line-height: 1.4;
|
|
}}
|
|
.comp {{
|
|
font-size: 9px;
|
|
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>""" |