feat: OG link previews — track image + meta tags for Telegram/WhatsApp

- 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
This commit is contained in:
Davide Scaini
2026-05-23 21:44:19 +02:00
parent 56932f7f25
commit 693f720cbd
6 changed files with 574 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
"""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"},
)