thermograph/content.py

403 lines
17 KiB
Python
Raw Normal View History

"""Server-rendered, crawlable content pages (climate hub / per-city / month /
records / glossary / about) plus robots.txt and sitemap.xml.
Ported from backend/web/content.py (repo-split Stage 3): every route handler
here fetches its data from the backend's SSR content API (api_client.py)
instead of importing backend code and computing in-process. Template shape is
unchanged -- only the data source changed, from a function call to an HTTP
call. page_title/page_description/canonical_path/breadcrumb/jsonld are all
computed by the backend now (backend/api/content_payloads.py), not here.
"""
import datetime
import hashlib
import json
import os
import httpx
from fastapi import HTTPException, Request, Response
from fastapi.responses import PlainTextResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
import api_client
import content_loader
import format as fmt
import paths
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
_env = Environment(
loader=FileSystemLoader(paths.TEMPLATES_DIR),
autoescape=select_autoescape(["html", "xml", "j2"]),
trim_blocks=True,
lstrip_blocks=True,
)
_env.globals["temp_class"] = fmt.temp_class
_env.globals["temp"] = fmt.temp
_env.globals["temp_bare"] = fmt.temp_bare
_env.filters["ordinal"] = fmt.pct_ordinal
# --- helpers -------------------------------------------------------------
def _origin(request: Request) -> str:
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
return f"{proto}://{host}"
def _respond_html(request: Request, template: str, **ctx) -> Response:
o = _origin(request)
ctx.setdefault("unit_default", fmt.current_unit())
html = _env.get_template(template).render(base=BASE, origin=o, base_url=f"{o}{BASE}", **ctx)
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
inm = request.headers.get("if-none-match")
if inm and etag in {t.strip() for t in inm.split(",")}:
return Response(status_code=304, headers={"ETag": etag})
return Response(html, media_type="text/html", headers={"ETag": etag})
def _breadcrumb_tuples(breadcrumb: list[dict]) -> list[tuple]:
"""The templates do `{% for label, href in breadcrumb %}` (tuple
unpacking) -- the backend returns [{"name","href"}, ...] (JSON-safe), so
convert once here rather than touching every template."""
return [(c["name"], c["href"]) for c in breadcrumb]
def _raise_api_error(e: httpx.HTTPStatusError):
"""The backend's 404 (unknown city/month) and 503 (warming) map straight
through -- same status codes _resolve_city raised in-process before."""
raise HTTPException(status_code=e.response.status_code, detail=e.response.text) from e
# --- robots.txt & sitemap.xml ---------------------------------------------
def robots_txt(request: Request) -> Response:
base_url = f"{_origin(request)}{BASE}"
body = (
"User-agent: *\n"
"Allow: /\n"
f"Disallow: {BASE}/api/\n"
f"Disallow: {BASE}/alerts\n"
f"Sitemap: {base_url}/sitemap.xml\n"
)
return PlainTextResponse(body)
# A stable per-process "content last built" date -- crawlers should see
# <lastmod> change on a real rebuild, not churn on every request.
_BOOT_DATE = datetime.date.today().isoformat()
def sitemap_xml(request: Request) -> Response:
base_url = f"{_origin(request)}{BASE}"
entries = api_client.sitemap()
parts = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for e in entries:
parts.append(
f"<url><loc>{base_url}{e['path']}</loc><lastmod>{_BOOT_DATE}</lastmod>"
f"<changefreq>{e['changefreq']}</changefreq><priority>{e['priority']}</priority></url>"
)
parts.append("</urlset>")
return Response("\n".join(parts), media_type="application/xml")
def head_verify_html() -> Markup:
"""Search-engine ownership-verification <meta> tags, from env (empty when
unset). No backend dependency -- same env vars, read directly here."""
metas = []
google = os.environ.get("THERMOGRAPH_GOOGLE_VERIFY", "").strip()
bing = os.environ.get("THERMOGRAPH_BING_VERIFY", "").strip()
if google:
metas.append(Markup('<meta name="google-site-verification" content="{}">').format(google))
if bing:
metas.append(Markup('<meta name="msvalidate.01" content="{}">').format(bing))
return Markup("\n ").join(metas)
_env.globals["head_verify"] = head_verify_html
# --- per-city climate pages ------------------------------------------------
def _fmt_months(months: list[dict]) -> list[dict]:
out = []
for m in months:
out.append({
**m,
"high": fmt.temp(m["high_f"]), "low": fmt.temp(m["low_f"]),
"precip": fmt.precip(m["precip_f"]),
"bar": fmt.range_bar(m["range_lo_f"], m["range_hi_f"]),
})
return out
def city_page(request: Request, slug: str) -> Response:
try:
j = api_client.city(slug, origin=_origin(request))
except httpx.HTTPStatusError as e:
_raise_api_error(e)
with fmt.unit_scope(j["default_unit"]):
months = _fmt_months(j["months"])
warmest = next((m for m in months if m["slug"] == j["warmest_month_slug"]), None)
coldest = next((m for m in months if m["slug"] == j["coldest_month_slug"]), None)
wettest = next((m for m in months if m["slug"] == j["wettest_month_slug"]), None)
records = j["all_time_records"]
today = None
if j["today_vs_normal"]:
today = {
"date": j["today_vs_normal"]["date"],
"cards": [{**c, "value": fmt.fmt(c["metric"], c["value_f"])}
for c in j["today_vs_normal"]["cards"]],
}
ctx = {
"section": "climate",
"city": j["city"], "display": j["display"], "name": j["city"]["name"],
"year_range": j["year_range"], "n_years": j["n_years"],
"months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest,
"records": records, "today": today,
"tool_hash": f"{j['city']['lat']:.5f},{j['city']['lon']:.5f}",
"flavor": j["flavor"], "event": j["event"],
"compare_url": f"{BASE}/compare#loc={j['city']['lat']:.4f},{j['city']['lon']:.4f}",
"breadcrumb": _breadcrumb_tuples(j["breadcrumb"]),
"canonical_path": j["canonical_path"],
"page_title": j["page_title"], "page_description": j["page_description"],
"jsonld_str": json.dumps(j["jsonld"], ensure_ascii=False, separators=(",", ":")),
}
return _respond_html(request, "city.html.j2", **ctx)
# --- month & records pages --------------------------------------------------
_METRIC_KEY_BY_LABEL = {
"High": "tmax", "Low": "tmin", "Feels-like": "feels",
"Humidity": "humid", "Wind": "wind", "Gust": "gust", "Precip": "precip",
}
def month_page(request: Request, slug: str, month: str) -> Response:
try:
j = api_client.city_month(slug, month)
city = api_client.city(slug)["city"]
except httpx.HTTPStatusError as e:
_raise_api_error(e)
with fmt.unit_scope(fmt.unit_for_country(city.get("country_code"))):
stats = []
if j["avg_high_f"] is not None:
stats.append(("Average high", fmt.temp(j["avg_high_f"])))
lo, hi = j["typical_high_range_f"]
stats.append(("Typical high range", fmt.temp(lo) + " to " + fmt.temp(hi)))
if j["avg_low_f"] is not None:
stats.append(("Average low", fmt.temp(j["avg_low_f"])))
lo, hi = j["typical_low_range_f"]
stats.append(("Typical low range", fmt.temp(lo) + " to " + fmt.temp(hi)))
if j["avg_precip_f"] is not None:
stats.append(("Average daily precipitation", fmt.precip(j["avg_precip_f"])))
records = []
for r in j["records"]:
metric = _METRIC_KEY_BY_LABEL[r["label"]]
records.append({
"label": r["label"], "high_tag": r["high_tag"], "low_tag": r["low_tag"],
"high": fmt.fmt(metric, r["high_f"]), "high_date": r["high_date"],
"low": fmt.fmt(metric, r["low_f"]), "low_date": r["low_date"],
})
ctx = {
"section": "climate", "city": city, "display": j.get("display", city["name"]), "name": city["name"],
"month_name": j["month_name"], "month_slug": j["month_slug"],
"year_range": j["year_range"], "n_years": j["n_years"],
"avg_high": fmt.temp(j["avg_high_f"]), "avg_low": fmt.temp(j["avg_low_f"]),
"avg_high_cls": fmt.temp_class(j["avg_high_f"]), "avg_low_cls": fmt.temp_class(j["avg_low_f"]),
"stats": stats, "records": records,
"tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}",
"compare_url": f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}",
"prev": j["prev"], "next": j["next"],
"breadcrumb": _breadcrumb_tuples(j["breadcrumb"]),
"canonical_path": j["canonical_path"],
"page_title": j["page_title"], "page_description": j["page_description"],
}
return _respond_html(request, "month.html.j2", **ctx)
def _fmt_extreme(e: dict | None) -> dict | None:
if not e:
return None
return {"txt": fmt.temp(e["value_f"]), "cls": fmt.temp_class(e["value_f"]), "date": e["date"]}
def _fmt_period_extremes(side: dict) -> dict:
"""{'warm': {'value_f','date'}|None, 'cold': ...} -> the {'cls','txt','date'}
shape records.html.j2's `extremes()` macro reads."""
return {k: _fmt_extreme(v) for k, v in side.items()}
def records_page(request: Request, slug: str) -> Response:
try:
j = api_client.city_records(slug, origin=_origin(request))
city = api_client.city(slug)["city"]
except httpx.HTTPStatusError as e:
_raise_api_error(e)
with fmt.unit_scope(fmt.unit_for_country(city.get("country_code"))):
rows = []
for r in j["rows"]:
metric = _METRIC_KEY_BY_LABEL[r["label"]]
if r["dry_streak_days"] is not None:
low, low_date = f"{r['dry_streak_days']}-day dry spell", r["low_date"] or ""
else:
low, low_date = fmt.fmt(metric, r["low_f"]), r["low_date"]
is_temp = metric in ("tmax", "tmin", "feels")
rows.append({
"label": r["label"],
"high": fmt.fmt(metric, r["high_f"]), "high_date": r["high_date"],
"high_f": r["high_f"] if is_temp else None,
"low": low, "low_date": low_date,
"low_f": r["low_f"] if is_temp else None,
})
monthly = [{"name": m["name"], "slug": m["slug"],
"high": _fmt_period_extremes(m["high"]), "low": _fmt_period_extremes(m["low"])}
for m in j["monthly"]]
seasonal = [{"name": s["name"], "span": s["span"],
"high": _fmt_period_extremes(s["high"]), "low": _fmt_period_extremes(s["low"])}
for s in j["seasonal"]]
ctx = {
"section": "climate", "city": city, "display": j.get("display", city["name"]), "name": city["name"],
"year_range": j["year_range"], "n_years": j["n_years"],
"rows": rows, "monthly": monthly, "seasonal": seasonal,
"hemisphere": j["hemisphere"], "all_time": j["all_time_records"],
"canonical_path": j["canonical_path"],
"breadcrumb": _breadcrumb_tuples(j["breadcrumb"]),
"page_title": j["page_title"], "page_description": j["page_description"],
"jsonld_str": json.dumps(j["jsonld"], ensure_ascii=False, separators=(",", ":")),
}
return _respond_html(request, "records.html.j2", **ctx)
# --- hub / glossary / about -------------------------------------------------
def hub_page(request: Request) -> Response:
h = api_client.hub()
groups = {c["country"]: c["cities"] for c in h["countries"]}
ctx = {
"section": "climate",
"groups": groups,
"n_cities": h["n_cities"],
"n_countries": h["n_countries"],
"canonical_path": "/climate",
"breadcrumb": [("Home", f"{BASE}/"), ("Climate", None)],
"page_title": PAGES["hub"]["title"],
"page_description": PAGES["hub"]["description"],
}
return _respond_html(request, "hub.html.j2", **ctx)
GLOSSARY: dict[str, dict] = content_loader.load_glossary()
PAGES: dict[str, dict] = content_loader.load_pages()
def _glossary_body(entry: dict) -> str:
return entry["body"].replace("{base}", BASE)
def glossary_index(request: Request) -> Response:
ctx = {
"terms": [{"slug": s, **e} for s, e in GLOSSARY.items()],
"canonical_path": "/glossary",
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", None)],
"page_title": PAGES["glossary_index"]["title"],
"page_description": PAGES["glossary_index"]["description"],
}
return _respond_html(request, "glossary.html.j2", **ctx)
def glossary_term(request: Request, term: str) -> Response:
entry = GLOSSARY.get(term)
if entry is None:
raise HTTPException(status_code=404, detail="Unknown term.")
ctx = {
"term": entry["term"], "body": _glossary_body(entry),
"canonical_path": f"/glossary/{term}",
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", f"{BASE}/glossary"), (entry["term"], None)],
"page_title": f"{entry['term']}: what it means | Thermograph",
"page_description": entry["short"],
"others": [{"slug": s, "term": e["term"]} for s, e in GLOSSARY.items() if s != term],
}
return _respond_html(request, "glossary_term.html.j2", **ctx)
def about_page(request: Request) -> Response:
ctx = {
"canonical_path": "/about",
"breadcrumb": [("Home", f"{BASE}/"), ("About", None)],
"page_title": PAGES["about"]["title"],
"page_description": PAGES["about"]["description"],
}
return _respond_html(request, "about.html.j2", **ctx)
def privacy_page(request: Request) -> Response:
ctx = {
"canonical_path": "/privacy",
"breadcrumb": [("Home", f"{BASE}/"), ("Privacy", None)],
"page_title": PAGES["privacy"]["title"],
"page_description": PAGES["privacy"]["description"],
}
return _respond_html(request, "privacy.html.j2", **ctx)
# --- homepage ----------------------------------------------------------------
_HOME_JSONLD = {
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Thermograph",
"applicationCategory": "WeatherApplication",
"operatingSystem": "Web, iOS, Android",
"isAccessibleForFree": True,
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
"description": ("How unusual is your weather? Any day, anywhere on Earth, graded "
"against 45 years of that place's own history."),
}
def home_page(request: Request) -> Response:
h = api_client.home()
ctx = {
"section": "home",
"brand_tag": "p",
"main_class": "",
"canonical_path": "/",
"unusual": h["unusual"],
"stale": h["stale"],
"ranked": h["ranked"],
"cities": h["cities"],
"jsonld_str": Markup(json.dumps({**_HOME_JSONLD, "url": f"{_origin(request)}{BASE}/"})),
}
return _respond_html(request, "home.html.j2", **ctx)
# --- registration ------------------------------------------------------------
def register(app) -> None:
"""Attach all content routes. Call from app.py BEFORE the StaticFiles mount."""
app.add_api_route(f"{BASE}/robots.txt", robots_txt, methods=["GET"], include_in_schema=False)
app.add_api_route(f"{BASE}/sitemap.xml", sitemap_xml, methods=["GET"], include_in_schema=False)
_inkey = api_client.indexnow_key()
def _indexnow_key_file() -> Response:
return PlainTextResponse(_inkey + "\n")
app.add_api_route(
f"{BASE}/{_inkey}.txt", _indexnow_key_file,
methods=["GET", "HEAD"], include_in_schema=False,
)
app.add_api_route(f"{BASE}/", home_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/about", about_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/privacy", privacy_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/glossary", glossary_index, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/glossary/{{term}}", glossary_term, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate", hub_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate/{{slug}}", city_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate/{{slug}}/records", records_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate/{{slug}}/{{month}}", month_page, methods=["GET", "HEAD"], include_in_schema=False)