Files
bincio-activity/bincio/serve/routers/ogimage.py
T
Davide Scaini 693f720cbd 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
2026-05-23 21:44:19 +02:00

162 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"},
)