Update api/main.py
All checks were successful
Build & Push Football Docker Images / build-push-update (push) Successful in 7s
All checks were successful
Build & Push Football Docker Images / build-push-update (push) Successful in 7s
This commit is contained in:
222
api/main.py
222
api/main.py
@@ -20,8 +20,13 @@ from fastapi.responses import HTMLResponse
|
|||||||
DATA_FILE = Path("/data/porto.json")
|
DATA_FILE = Path("/data/porto.json")
|
||||||
PT_TZ = ZoneInfo("Europe/Lisbon")
|
PT_TZ = ZoneInfo("Europe/Lisbon")
|
||||||
|
|
||||||
app = FastAPI(title="FC Porto API", version="1.0.0")
|
app = FastAPI(title="FC Porto API", version="2.0.0")
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["GET"], allow_headers=["*"])
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["GET"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load() -> dict:
|
def load() -> dict:
|
||||||
@@ -30,6 +35,34 @@ def load() -> dict:
|
|||||||
return json.loads(DATA_FILE.read_text())
|
return json.loads(DATA_FILE.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def sorted_matches(d: dict):
|
||||||
|
"""Return all matches sorted by timestamp, fixing any year-rollover issues."""
|
||||||
|
now_ts = datetime.now(PT_TZ).timestamp()
|
||||||
|
|
||||||
|
past = []
|
||||||
|
future = []
|
||||||
|
|
||||||
|
for m in d.get("all_past", []):
|
||||||
|
if m.get("timestamp", 0) > 0:
|
||||||
|
past.append(m)
|
||||||
|
|
||||||
|
for m in d.get("all_future", []):
|
||||||
|
if m.get("timestamp", 0) > 0:
|
||||||
|
future.append(m)
|
||||||
|
|
||||||
|
# Sort both
|
||||||
|
past.sort(key=lambda x: x["timestamp"])
|
||||||
|
future.sort(key=lambda x: x["timestamp"])
|
||||||
|
|
||||||
|
# Edge case: some "future" matches may have wrong year from scraper
|
||||||
|
# Keep only those with timestamp > now
|
||||||
|
future = [m for m in future if m["timestamp"] > now_ts]
|
||||||
|
# Keep only past with timestamp <= now
|
||||||
|
past = [m for m in past if m["timestamp"] <= now_ts]
|
||||||
|
|
||||||
|
return past, future
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok", "data_exists": DATA_FILE.exists()}
|
return {"status": "ok", "data_exists": DATA_FILE.exists()}
|
||||||
@@ -43,154 +76,187 @@ def data():
|
|||||||
@app.get("/next")
|
@app.get("/next")
|
||||||
def next_match():
|
def next_match():
|
||||||
d = load()
|
d = load()
|
||||||
future = d.get("all_future", [])
|
_, future = sorted_matches(d)
|
||||||
if not future:
|
if not future:
|
||||||
return {"opponent": "No upcoming matches", "date": "—", "competition": "—", "channel": "—"}
|
return {"opponent": "No upcoming matches", "date": "—", "competition": "—", "venue": "—"}
|
||||||
m = future[0]
|
m = future[0]
|
||||||
return {
|
return {
|
||||||
"opponent": m["opponent"],
|
"opponent": m["opponent"],
|
||||||
"date": f"{m['date']} {m['time']}",
|
"date": f"{m['date']} {m['time']}",
|
||||||
"competition": m["competition"],
|
"competition": m["competition"],
|
||||||
"channel": m["channel"],
|
|
||||||
"venue": "Home" if m["is_home"] else "Away",
|
"venue": "Home" if m["is_home"] else "Away",
|
||||||
|
"home": m["home"],
|
||||||
|
"away": m["away"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/widget", response_class=HTMLResponse)
|
@app.get("/widget", response_class=HTMLResponse)
|
||||||
def widget():
|
def widget():
|
||||||
"""Self-contained HTML widget for Homepage iframe top bar — 2 row layout."""
|
"""
|
||||||
|
Layout: 3 past results + 2 upcoming fixtures
|
||||||
|
|
||||||
|
For each match card:
|
||||||
|
[ home badge ]
|
||||||
|
[ home name ]
|
||||||
|
[ score/date ]
|
||||||
|
[ away badge ]
|
||||||
|
[ away name ]
|
||||||
|
[ comp abbr ]
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
d = load()
|
d = load()
|
||||||
matches = d.get("display", [])
|
past, future = sorted_matches(d)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
matches = []
|
past, future = [], []
|
||||||
|
|
||||||
# Competition abbreviations
|
# Last 3 past + next 2 future
|
||||||
ABBR = {
|
last3 = past[-3:] if len(past) >= 3 else past
|
||||||
"Liga Portugal": "LP",
|
next2 = future[:2] if len(future) >= 2 else future
|
||||||
"Champions League": "CL",
|
|
||||||
"Europa League": "UE",
|
# Pad with empties
|
||||||
"Taça de Portugal": "TP",
|
empty = {
|
||||||
"Taça da Liga": "TL",
|
"home": "—", "away": "—",
|
||||||
"Supertaça": "ST",
|
"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))
|
||||||
|
|
||||||
# Pad to exactly 4
|
all_cards = last3 + next2
|
||||||
empty = {"home": "—", "away": "—", "competition": "—",
|
|
||||||
"date": "—", "time": "—", "score": None, "is_past": False, "channel": "—"}
|
|
||||||
while len(matches) < 4:
|
|
||||||
matches.append(empty)
|
|
||||||
matches = matches[:4]
|
|
||||||
|
|
||||||
def match_html(m: dict, idx: int) -> str:
|
PORTO_LOGO = "https://a.espncdn.com/i/teamlogos/soccer/500/437.png"
|
||||||
is_past = m.get("is_past", False)
|
|
||||||
score = m.get("score")
|
def card(m: dict, is_past: bool) -> str:
|
||||||
home = m.get("home", "—")
|
home = m.get("home", "—")
|
||||||
away = m.get("away", "—")
|
away = m.get("away", "—")
|
||||||
comp = m.get("competition", "—")
|
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", "—")
|
date = m.get("date", "—")
|
||||||
time_ = m.get("time", "")
|
time_ = m.get("time", "")
|
||||||
channel = m.get("channel", "")
|
|
||||||
|
|
||||||
abbr = ABBR.get(comp, comp[:2].upper() if comp != "—" else "—")
|
# 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]
|
||||||
|
|
||||||
# Row 1 — "Porto vs Benfica (LP)"
|
home_s = shorten(home)
|
||||||
row1 = f"{home} vs {away} <span class='abbr'>({abbr})</span>"
|
away_s = shorten(away)
|
||||||
|
|
||||||
# Row 2 — score or date · channel
|
|
||||||
if is_past and score:
|
if is_past and score:
|
||||||
row2 = f'<span class="score">{score}</span>'
|
middle = f'<div class="score">{score}</div>'
|
||||||
card_class = "match past"
|
cls = "card past"
|
||||||
else:
|
else:
|
||||||
# shorten date: dd/mm/yy → dd/mm
|
date_s = "/".join(date.split("/")[:2]) if "/" in date else date
|
||||||
short_date = "/".join(date.split("/")[:2]) if "/" in date else date
|
time_s = f"<br>{time_}" if time_ and time_ != "TBD" else ""
|
||||||
time_part = f" {time_}" if time_ and time_ != "TBD" else ""
|
middle = f'<div class="fixture">{date_s}{time_s}</div>'
|
||||||
ch_part = f" · {channel}" if channel and channel not in ("TBD", "—", "") else ""
|
cls = "card future"
|
||||||
row2 = f'<span class="fixture">{short_date}{time_part}{ch_part}</span>'
|
|
||||||
card_class = "match future"
|
|
||||||
|
|
||||||
divider = '<div class="divider"></div>' if idx == 1 else ""
|
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
{divider}
|
<div class="{cls}">
|
||||||
<div class="{card_class}">
|
<img src="{home_logo}" class="badge" alt="{home}">
|
||||||
<div class="row1">{row1}</div>
|
<div class="name home-name">{home_s}</div>
|
||||||
<div class="row2">{row2}</div>
|
{middle}
|
||||||
|
<img src="{away_logo}" class="badge" alt="{away}">
|
||||||
|
<div class="name away-name">{away_s}</div>
|
||||||
|
<div class="comp">{abbr}</div>
|
||||||
</div>"""
|
</div>"""
|
||||||
|
|
||||||
cards = "".join(match_html(m, i) for i, m in enumerate(matches))
|
past_cards = "".join(card(m, True) for m in last3)
|
||||||
|
future_cards = "".join(card(m, False) for m in next2)
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<style>
|
<style>
|
||||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
||||||
body {{
|
body {{
|
||||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
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 {{
|
.container {{
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 6px;
|
padding: 0 4px;
|
||||||
}}
|
}}
|
||||||
.match {{
|
.card {{
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2px 6px;
|
padding: 2px 4px;
|
||||||
gap: 3px;
|
gap: 1px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}}
|
}}
|
||||||
.past {{ opacity: 0.72; }}
|
.past {{ opacity: 0.7; }}
|
||||||
.future {{ opacity: 1; }}
|
.future {{ opacity: 1; }}
|
||||||
.row1 {{
|
.badge {{
|
||||||
font-size: 11px;
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}}
|
||||||
|
.name {{
|
||||||
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
width: 100%;
|
max-width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #f1f5f9;
|
|
||||||
}}
|
|
||||||
.future .row1 {{ color: #93c5fd; }}
|
|
||||||
.abbr {{
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #64748b;
|
|
||||||
}}
|
|
||||||
.row2 {{
|
|
||||||
font-size: 10px;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}}
|
}}
|
||||||
|
.home-name {{ color: #cbd5e1; }}
|
||||||
|
.away-name {{ color: #94a3b8; }}
|
||||||
.score {{
|
.score {{
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
color: #4ade80;
|
color: #4ade80;
|
||||||
font-weight: 700;
|
padding: 1px 0;
|
||||||
}}
|
}}
|
||||||
.fixture {{ color: #94a3b8; }}
|
.fixture {{
|
||||||
.divider {{
|
font-size: 9px;
|
||||||
width: 1px;
|
color: #93c5fd;
|
||||||
background: #1e293b;
|
text-align: center;
|
||||||
margin: 6px 0;
|
line-height: 1.3;
|
||||||
flex-shrink: 0;
|
}}
|
||||||
align-self: stretch;
|
.comp {{
|
||||||
|
font-size: 8px;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 500;
|
||||||
}}
|
}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{cards}
|
<div class="section">{past_cards}</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="section">{future_cards}</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
Reference in New Issue
Block a user