403 lines
17 KiB
Python
403 lines
17 KiB
Python
|
|
"""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)
|