693f720cbd
- bincio/render/ogimage.py: generate 400x400 elevation-coloured PNG with Pillow
- bincio/serve/routers/ogimage.py: /activity/{id}/ OG HTML stub for bot UAs;
/og-image/{user}/{id}.png serves pre-generated images with on-demand fallback
- scripts/generate_og_images.py: batch pre-generation, incremental (mtime skip)
- scripts/strava_elevation_audit.py: add source/threshold/MA columns and pct stats
- pyproject.toml: add Pillow>=10 to serve extras
162 lines
5.4 KiB
Python
162 lines
5.4 KiB
Python
"""OG preview endpoints.
|
||
|
||
GET /activity/{activity_id}
|
||
Returns a minimal HTML page with Open Graph meta tags for social link
|
||
previews (Telegram, WhatsApp, Slack, …). nginx proxies only bot
|
||
User-Agents here; regular browsers still get the static SPA shell.
|
||
|
||
GET /api/og-image/{user_handle}/{activity_id}.png
|
||
Returns the pre-generated 400×400 track PNG. Falls back to generating
|
||
on the fly if the static file doesn't exist yet (e.g. a brand-new import
|
||
before the next deploy-time generation run).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
import json
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
from fastapi.responses import HTMLResponse, Response
|
||
|
||
from bincio.serve import deps
|
||
|
||
router = APIRouter()
|
||
|
||
# Sport → emoji map (extend as needed)
|
||
_SPORT_EMOJI: dict[str, str] = {
|
||
"cycling": "🚴",
|
||
"running": "🏃",
|
||
"swimming": "🏊",
|
||
"hiking": "🥾",
|
||
"walking": "🚶",
|
||
"skiing": "⛷️",
|
||
"rowing": "🚣",
|
||
"triathlon": "🏊",
|
||
"e_cycling": "🚴",
|
||
"gravel": "🚴",
|
||
}
|
||
|
||
|
||
def _find_user(data_dir: Path, activity_id: str) -> str | None:
|
||
"""Return the user handle that owns *activity_id*, or None."""
|
||
for user_dir in sorted(data_dir.iterdir()):
|
||
if not user_dir.is_dir() or user_dir.name.startswith("_") or user_dir.name == "segments":
|
||
continue
|
||
if (user_dir / "activities" / f"{activity_id}.json").exists():
|
||
return user_dir.name
|
||
return None
|
||
|
||
|
||
def _fmt_description(detail: dict, handle: str) -> str:
|
||
parts: list[str] = []
|
||
sport = (detail.get("sport") or "").lower()
|
||
emoji = _SPORT_EMOJI.get(sport, "🏅")
|
||
parts.append(emoji)
|
||
|
||
dist_m = detail.get("distance_m")
|
||
if dist_m:
|
||
parts.append(f"{dist_m / 1000:.1f} km")
|
||
|
||
gain = detail.get("elevation_gain_m")
|
||
if gain:
|
||
parts.append(f"{gain:.0f} m ↑")
|
||
|
||
dur = detail.get("moving_time_s") or detail.get("duration_s")
|
||
if dur:
|
||
h, rem = divmod(int(dur), 3600)
|
||
m = rem // 60
|
||
parts.append(f"{h}h {m:02d}m" if h else f"{m}m")
|
||
|
||
started = detail.get("started_at")
|
||
if started:
|
||
try:
|
||
dt = datetime.fromisoformat(started).astimezone(timezone.utc)
|
||
parts.append(dt.strftime("%-d %b %Y"))
|
||
except ValueError:
|
||
pass
|
||
|
||
parts.append(f"@{handle}")
|
||
return " · ".join(parts)
|
||
|
||
|
||
@router.get("/activity/{activity_id}", response_class=HTMLResponse, include_in_schema=False)
|
||
@router.get("/activity/{activity_id}/", response_class=HTMLResponse, include_in_schema=False)
|
||
async def og_preview(activity_id: str, request: Request) -> HTMLResponse:
|
||
data_dir = deps._get_data_dir()
|
||
handle = _find_user(data_dir, activity_id)
|
||
if handle is None:
|
||
raise HTTPException(404)
|
||
|
||
json_path = data_dir / handle / "activities" / f"{activity_id}.json"
|
||
detail = json.loads(json_path.read_text(encoding="utf-8"))
|
||
|
||
title = detail.get("title") or activity_id
|
||
desc = _fmt_description(detail, handle)
|
||
base = str(request.base_url).rstrip("/")
|
||
img_url = f"{base}/og-image/{handle}/{activity_id}.png"
|
||
act_url = f"{base}/activity/{activity_id}/"
|
||
|
||
h_title = html.escape(title)
|
||
h_desc = html.escape(desc)
|
||
h_img = html.escape(img_url)
|
||
h_url = html.escape(act_url)
|
||
|
||
content = f"""<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>{h_title} – BincioActivity</title>
|
||
<meta property="og:title" content="{h_title}" />
|
||
<meta property="og:description" content="{h_desc}" />
|
||
<meta property="og:image" content="{h_img}" />
|
||
<meta property="og:image:width" content="400" />
|
||
<meta property="og:image:height" content="400" />
|
||
<meta property="og:url" content="{h_url}" />
|
||
<meta property="og:type" content="website" />
|
||
<meta property="og:site_name" content="BincioActivity" />
|
||
<meta name="twitter:card" content="summary" />
|
||
<meta name="twitter:title" content="{h_title}" />
|
||
<meta name="twitter:description" content="{h_desc}" />
|
||
<meta name="twitter:image" content="{h_img}" />
|
||
<script>window.location.replace("{act_url}");</script>
|
||
</head>
|
||
<body></body>
|
||
</html>"""
|
||
return HTMLResponse(content=content)
|
||
|
||
|
||
@router.get("/og-image/{user_handle}/{activity_id}.png", include_in_schema=False)
|
||
async def og_image(user_handle: str, activity_id: str) -> Response:
|
||
data_dir = deps._get_data_dir()
|
||
www_root = Path("/var/www/activity")
|
||
img_path = www_root / "og-image" / user_handle / f"{activity_id}.png"
|
||
|
||
if img_path.exists():
|
||
return Response(
|
||
content=img_path.read_bytes(),
|
||
media_type="image/png",
|
||
headers={"Cache-Control": "public, max-age=86400"},
|
||
)
|
||
|
||
# Fallback: generate on the fly (e.g. new activity before next deploy run)
|
||
ts_path = data_dir / user_handle / "activities" / f"{activity_id}.timeseries.json"
|
||
if not ts_path.exists():
|
||
raise HTTPException(404)
|
||
|
||
try:
|
||
from bincio.render.ogimage import generate_for_activity
|
||
png = generate_for_activity(ts_path)
|
||
img_path.parent.mkdir(parents=True, exist_ok=True)
|
||
img_path.write_bytes(png)
|
||
except Exception:
|
||
raise HTTPException(500, "Image generation failed")
|
||
|
||
return Response(
|
||
content=png,
|
||
media_type="image/png",
|
||
headers={"Cache-Control": "public, max-age=86400"},
|
||
)
|