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:
@@ -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"},
|
||||
)
|
||||
Reference in New Issue
Block a user