thermograph/content.py
Emi Griffith ad68caa754 Fix BreadcrumbList JSON-LD: omit unlinked intermediate crumbs (#164)
Google Search Console flags city pages with 'Missing field item (in
itemListElement)': the country crumb has no page of its own, so its
ListItem was emitted without the required item URL. Google only allows
the final ListItem to omit item, so drop unlinked intermediate crumbs
from the structured data (the visible breadcrumb is unchanged) and
renumber positions. Shared helper now builds the BreadcrumbList for
both the city and records pages, with a regression test parsing the
emitted JSON-LD.

Claude-Session: https://claude.ai/code/session_01KdTZCjpLeD26ZbXe6ApjpR
2026-07-17 13:04:51 +00:00

760 lines
35 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.

"""Server-rendered, crawlable content pages (climate hub / per-city / month /
records / glossary / about) plus robots.txt and sitemap.xml.
These are the SEO surface: real URLs with the climate stats in the HTML, rendered
with Jinja2 from the same builders the API uses, linking into the interactive tool.
Registered on the app (via register()) BEFORE the StaticFiles mount so they win.
"""
import datetime
import functools
import hashlib
import json
import os
import polars as pl
from fastapi import HTTPException, Request, Response
from fastapi.responses import PlainTextResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
import cities
import city_events
import climate
import grading
import grid
from views import OBS_COLS
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
_env = Environment(
loader=FileSystemLoader(TEMPLATES_DIR),
autoescape=select_autoescape(["html", "xml", "j2"]),
trim_blocks=True,
lstrip_blocks=True,
)
MONTHS = ["january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november", "december"]
MONTHS_TITLE = [m.capitalize() for m in MONTHS]
MONTH_INDEX = {m: i + 1 for i, m in enumerate(MONTHS)}
# Meteorological seasons as (northern-hemisphere label, southern-hemisphere label,
# month numbers, span text). The same three months are one season everywhere — only
# the name flips across the equator (DecFeb is winter in the north, summer in the
# south), so a city's latitude picks the label.
SEASONS = [
("Winter", "Summer", [12, 1, 2], "DecFeb"),
("Spring", "Autumn", [3, 4, 5], "MarMay"),
("Summer", "Winter", [6, 7, 8], "JunAug"),
("Autumn", "Spring", [9, 10, 11], "SepNov"),
]
# Display labels for the graded metrics (order = how they appear on the page).
METRIC_LABELS = [
("tmax", "High"), ("tmin", "Low"), ("feels", "Feels-like"),
("humid", "Humidity"), ("wind", "Wind"), ("gust", "Gust"), ("precip", "Precip"),
]
_TEMP_METRICS = {"tmax", "tmin", "feels"}
def _c(f: float) -> int:
return round((f - 32) * 5 / 9)
def _temp(f):
"""A Fahrenheit temperature as a client-convertible span, e.g.
'<span class="temp" data-temp-f="72.3">72°F</span>'. Renders °F by default
(so crawlers and no-JS visitors see a real value); climate.js rewrites it to
the active unit on load and on toggle. Returns '' for a missing value."""
if f is None:
return ""
return Markup('<span class="temp" data-temp-f="{:.1f}">{}°F</span>').format(f, round(f))
def _temp_bare(f):
"""Like _temp() but with no unit letter ('72°'), for the range strip where the
axis already implies the unit. Still carries data-temp-f so it converts."""
if f is None:
return ""
return Markup('<span class="temp" data-temp-f="{:.1f}" data-bare>{}°</span>').format(f, round(f))
def _fmt(metric: str, v) -> str:
if v is None:
return ""
if metric in _TEMP_METRICS:
return _temp(v)
if metric == "precip":
return f"{v:.2f} in"
if metric == "humid":
return f"{v:.1f} g/m³"
return f"{round(v)} mph" # wind, gust
# Absolute-temperature colour tiers (°F upper bounds) mapped to the site's diverging
# cold→hot palette — the same 9 tiers the interactive grader uses. Colouring records
# and normals by these turns the tables into a heat map in the site's own visual
# language, rather than a plain grid.
_TEMP_TIERS = [
(20, "rec-cold"), (32, "very-cold"), (45, "cold"), (58, "cool"),
(70, "normal"), (80, "warm"), (90, "hot"), (100, "very-hot"),
]
# °F axis for the monthly temperature-range strip on the city page.
_AXIS_LO, _AXIS_HI = -10.0, 115.0
def temp_class(f) -> str:
"""Diverging-palette tier name for an absolute Fahrenheit temperature (or 'none')."""
if f is None:
return "none"
for upper, cls in _TEMP_TIERS:
if f < upper:
return cls
return "rec-hot"
def _range_bar(low_f, high_f) -> dict | None:
"""Geometry for one month's low→high bar on the shared _AXIS_LO.._AXIS_HI axis:
left offset + width as percentages, and the tier colour at each end (for a
gradient fill). None when a value is missing."""
if low_f is None or high_f is None:
return None
lo = max(_AXIS_LO, min(_AXIS_HI, low_f))
hi = max(_AXIS_LO, min(_AXIS_HI, high_f))
span = _AXIS_HI - _AXIS_LO
return {
"left": round((lo - _AXIS_LO) / span * 100, 1),
"width": round(max(2.0, (hi - lo) / span * 100), 1),
"c1": temp_class(low_f), "c2": temp_class(high_f),
}
_env.globals["temp_class"] = temp_class
_env.globals["temp"] = _temp
_env.globals["temp_bare"] = _temp_bare
def _month_doy(month_idx: int) -> int:
return datetime.date(2001, month_idx, 15).timetuple().tm_yday
# --- 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 _breadcrumb_jsonld(o: str, breadcrumb: list[tuple[str, str | None]]) -> dict:
"""BreadcrumbList structured data. Google requires ``item`` on every
ListItem except the last, so unlinked intermediate crumbs (e.g. the
country, which has no page of its own) are omitted here even though the
visible breadcrumb shows them."""
crumbs = [c for c in breadcrumb[:-1] if c[1]] + breadcrumb[-1:]
return {"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": nm,
**({"item": f"{o}{href}"} if href else {})}
for i, (nm, href) in enumerate(crumbs)]}
def _respond_html(request: Request, template: str, **ctx) -> Response:
o = origin(request)
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})
# --- 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" # JSON endpoints, nothing to index
f"Disallow: {BASE}/alerts\n" # per-user, requires login
f"Sitemap: {base_url}/sitemap.xml\n"
)
return PlainTextResponse(body)
def _sitemap_entries() -> list[tuple[str, str, str]]:
"""Every indexable page as (path, changefreq, priority). Paths are relative to
BASE. Shared by the sitemap and IndexNow so the two never drift."""
entries = [("/", "daily", "1.0")]
for p in ("/climate", "/about", "/glossary", "/calendar", "/compare", "/legend"):
entries.append((p, "weekly", "0.6"))
for slug in cities.all_slugs():
entries.append((f"/climate/{slug}", "daily", "0.8"))
entries.append((f"/climate/{slug}/records", "monthly", "0.5"))
for m in MONTHS:
entries.append((f"/climate/{slug}/{m}", "monthly", "0.5"))
return entries
def public_paths() -> list[str]:
"""BASE-relative paths of every indexable page — for IndexNow submission."""
return [p for p, _, _ in _sitemap_entries()]
@functools.lru_cache(maxsize=1)
def _content_lastmod() -> str:
"""A stable 'content last built' date for <lastmod> — the newest mtime of the
city list and this module (both change on a content/code rebuild, and the
process restarts on deploy). Honest and stable, unlike a per-request today()
that churns every fetch and trains crawlers to ignore lastmod entirely."""
paths = [os.path.join(os.path.dirname(__file__), "cities.json"), __file__]
mtimes = [os.path.getmtime(p) for p in paths if os.path.exists(p)]
day = datetime.date.fromtimestamp(max(mtimes)) if mtimes else datetime.date.today()
return day.isoformat()
def sitemap_xml(request: Request) -> Response:
base_url = f"{origin(request)}{BASE}"
lastmod = _content_lastmod()
parts = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for path, cf, pr in _sitemap_entries():
parts.append(
f"<url><loc>{base_url}{path}</loc><lastmod>{lastmod}</lastmod>"
f"<changefreq>{cf}</changefreq><priority>{pr}</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). Injected into every page's <head> — Google verifies the homepage."""
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 _history_for(cell: dict):
"""Cached archive for a cell, fetching once if missing (self-heals like the
notifier). Returns a polars frame or None."""
hist = climate.load_cached_history(cell)
if hist is not None and not hist.is_empty():
return hist
try:
hist, _ = climate.get_history(cell)
except Exception: # noqa: BLE001 - upstream unavailable: caller renders a 503
return None
return hist if (hist is not None and not hist.is_empty()) else None
def _monthly_normals(history) -> list[dict]:
"""One row per month: average high/low and average precip, from the ±7-day
climatology around each month's 15th."""
rows = []
for i, name in enumerate(MONTHS_TITLE, start=1):
clim = grading.climatology(history, _month_doy(i))
tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip")
high_f = tmax["mean"] if tmax else None
low_f = tmin["mean"] if tmin else None
# The range strip spans the typical spread: 10th-percentile daily low to
# 90th-percentile daily high (the band most days fall within).
rng_lo = tmin["p10"] if tmin else None
rng_hi = tmax["p90"] if tmax else None
rows.append({
"name": name,
"slug": MONTHS[i - 1],
"high": _temp(tmax["mean"]) if tmax else "",
"high_f": high_f,
"low": _temp(tmin["mean"]) if tmin else "",
"low_f": low_f,
"range_lo_f": rng_lo,
"range_hi_f": rng_hi,
"precip": f"{precip['mean']:.2f} in" if precip else "",
"precip_v": precip["mean"] if precip else None,
"bar": _range_bar(rng_lo, rng_hi),
})
return rows
def _extreme(metric_rec, key: str) -> dict | None:
"""One extreme of a metric — key 'max' (warmest) or 'min' (coldest) — as the
two-unit value, its heat-map tier, and the date it occurred."""
if not metric_rec:
return None
v = metric_rec[key]
return {"txt": _temp(v), "f": v, "cls": temp_class(v), "date": metric_rec[f"{key}_date"]}
def _period_records(history, months: list[int]) -> dict:
"""For a set of calendar months, the record *warmest and coldest* of BOTH the
daytime high (tmax) and the overnight low (tmin) — four extremes with dates.
So each metric shows both ends: the daytime high's hottest day and the coldest a
day ever stayed (its record-low high), and the overnight low's mildest night and
its record low. Reuses grading.all_time_records on the month-filtered archive."""
if len(months) == 1:
sub = history.filter(pl.col("date").dt.month() == months[0])
else:
sub = history.filter(pl.col("date").dt.month().is_in(months))
rec = grading.all_time_records(sub) if not sub.is_empty() else {}
tmax, tmin = rec.get("tmax"), rec.get("tmin")
return {
"high": {"warm": _extreme(tmax, "max"), "cold": _extreme(tmax, "min")},
"low": {"warm": _extreme(tmin, "max"), "cold": _extreme(tmin, "min")},
}
def _monthly_records(history) -> list[dict]:
"""Record high and low for each of the 12 months (each row links to its month page)."""
return [
{"name": name, "slug": MONTHS[i - 1], **_period_records(history, [i])}
for i, name in enumerate(MONTHS_TITLE, start=1)
]
def _seasonal_records(history, lat: float) -> list[dict]:
"""Record high and low for each meteorological season, labelled for the city's
hemisphere (DecFeb reads as winter north of the equator, summer south of it)."""
south = lat < 0
return [
{"name": (south_lbl if south else north_lbl), "span": span,
**_period_records(history, months)}
for north_lbl, south_lbl, months, span in SEASONS
]
def _today_vs_normal(history, cell) -> dict | None:
"""Grade the latest recorded day against its climatology, for the hero block."""
try:
recent = climate.get_recent_forecast(cell)
except Exception: # noqa: BLE001
return None
if recent is None or recent.is_empty() or "date" not in recent.columns:
return None
today = datetime.date.today()
observed = recent.filter(pl.col("date") <= today).sort("date")
if observed.is_empty():
return None
row = observed.row(observed.height - 1, named=True)
obs = {k: row[k] for k in OBS_COLS if k in row}
graded = grading.grade_day(history, row["date"], obs)
cards = []
for key, label in METRIC_LABELS:
g = graded.get(key)
if not g:
continue
cards.append({
"label": label, "metric": key,
"value": _fmt(key, g.get("value")),
"percentile": g.get("percentile"),
"grade": g.get("grade"), "cls": g.get("class"),
})
date = row["date"]
return {
"date": date.isoformat() if hasattr(date, "isoformat") else str(date),
"cards": cards,
}
def _city_context(request, city, cell, history) -> dict:
name = city["name"]
display = cities.display_name(city)
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
months = _monthly_normals(history)
warmest = max((m for m in months if m["high_f"] is not None), key=lambda m: m["high_f"], default=None)
coldest = min((m for m in months if m["low_f"] is not None), key=lambda m: m["low_f"], default=None)
wettest = max((m for m in months if m["precip_v"] is not None), key=lambda m: m["precip_v"], default=None)
records = grading.all_time_records(history)
tool_hash = f"{city['lat']:.5f},{city['lon']:.5f}"
# Unique editorial blurb (Wikipedia, CC BY-SA) so the page isn't just templated
# stats, and a travel/comfort CTA that pre-fills this city on the compare page.
flavor = cities.flavor(city["slug"])
event = city_events.get(city["slug"]) # hand-curated; None → page falls back to the blurb
compare_url = f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}"
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate")]
if city.get("country"):
breadcrumb.append((city["country"], None))
breadcrumb.append((name, None))
o = origin(request)
page_url = f"{o}{BASE}/climate/{city['slug']}"
jsonld = {
"@context": "https://schema.org",
"@graph": [
{"@type": "Dataset",
"name": f"{display} climate normals and records",
"description": f"Average temperatures, precipitation and record highs and lows for "
f"{display}, from ~{year_range[1] - year_range[0]} years of daily climate history.",
"url": page_url,
"temporalCoverage": f"{year_range[0]}/{year_range[1]}",
"spatialCoverage": {"@type": "Place", "name": display,
"geo": {"@type": "GeoCoordinates",
"latitude": city["lat"], "longitude": city["lon"]}},
"creator": {"@type": "Organization", "name": "Thermograph"},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"},
_breadcrumb_jsonld(o, breadcrumb),
],
}
return {
"section": "climate",
"city": city, "display": display, "name": name,
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest,
"records": records, "today": _today_vs_normal(history, cell),
"tool_hash": tool_hash, "flavor": flavor, "event": event, "compare_url": compare_url,
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}",
"page_title": f"{display} climate: average temperatures, records & how today compares",
"page_description": (f"Average monthly high and low temperatures, rainfall, and all-time records "
f"for {display}, plus how today's weather compares — graded against "
f"~{year_range[1] - year_range[0]} years of local climate history."),
"jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")),
}
def _resolve_city(slug: str):
"""(city, cell, history) for a slug, or raise 404 (unknown) / 503 (warming)."""
city = cities.get(slug)
if city is None:
raise HTTPException(status_code=404, detail="Unknown city.")
cell = grid.snap(city["lat"], city["lon"])
history = _history_for(cell)
if history is None:
raise HTTPException(status_code=503, detail="Climate data is warming up; please retry shortly.")
return city, cell, history
def city_page(request: Request, slug: str) -> Response:
city, cell, history = _resolve_city(slug)
return _respond_html(request, "city.html.j2", **_city_context(request, city, cell, history))
# --- month & records pages ----------------------------------------------------
def _month_context(request, city, history, month_idx: int) -> dict:
display = cities.display_name(city)
name = city["name"]
month_name = MONTHS_TITLE[month_idx - 1]
month_slug = MONTHS[month_idx - 1]
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
clim = grading.climatology(history, _month_doy(month_idx))
tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip")
mdf = history.filter(pl.col("date").dt.month() == month_idx)
mrec = grading.all_time_records(mdf) if not mdf.is_empty() else {}
stats = []
if tmax:
stats.append(("Average high", _temp(tmax["mean"])))
stats.append(("Typical high range", _temp(tmax['p10']) + " to " + _temp(tmax['p90'])))
if tmin:
stats.append(("Average low", _temp(tmin["mean"])))
stats.append(("Typical low range", _temp(tmin['p10']) + " to " + _temp(tmin['p90'])))
if precip:
stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in"))
# Record high and low for every metric in this calendar month (mirrors the
# all-time records cards, but scoped to the month). Precip is special-cased:
# a per-day "record low" is just zero, and the dry-streak helper would wrongly
# bridge year boundaries on month-filtered rows, so the wettest/driest sides
# show this month's largest and smallest total accumulation, dated to the year.
records = []
for key, label in METRIC_LABELS:
r = mrec.get(key)
if not r:
continue
if key == "precip":
# Only whole months count — a partial current month would otherwise win
# "driest" on a fraction of its rainfall.
totals = (mdf.filter(pl.col("precip").is_not_null())
.group_by(pl.col("date").dt.year().alias("yr"))
.agg(pl.col("precip").sum().alias("total"),
pl.len().alias("days"))
.filter(pl.col("days") >= 26)
.sort("total"))
if totals.is_empty():
continue
lo, hi = totals.row(0, named=True), totals.row(totals.height - 1, named=True)
records.append({
"label": label,
"high": f"{hi['total']:.1f} in", "high_date": str(hi["yr"]), "high_tag": "Wettest",
"low": f"{lo['total']:.1f} in", "low_date": str(lo["yr"]), "low_tag": "Driest",
})
else:
records.append({
"label": label,
"high": _fmt(key, r["max"]), "high_date": r["max_date"], "high_tag": "Highest",
"low": _fmt(key, r["min"]), "low_date": r["min_date"], "low_tag": "Lowest",
})
prev_i = 12 if month_idx == 1 else month_idx - 1
next_i = 1 if month_idx == 12 else month_idx + 1
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"),
(name, f"{BASE}/climate/{city['slug']}"), (month_name, None)]
return {
"section": "climate", "city": city, "display": display, "name": name,
"month_name": month_name, "month_slug": month_slug,
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"avg_high": _temp(tmax["mean"]) if tmax else "",
"avg_low": _temp(tmin["mean"]) if tmin else "",
"avg_high_cls": temp_class(tmax["mean"]) if tmax else "none",
"avg_low_cls": temp_class(tmin["mean"]) if tmin else "none",
"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": {"name": MONTHS_TITLE[prev_i - 1], "slug": MONTHS[prev_i - 1]},
"next": {"name": MONTHS_TITLE[next_i - 1], "slug": MONTHS[next_i - 1]},
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}/{month_slug}",
"page_title": f"Average weather in {display} in {month_name}",
"page_description": (f"Average high and low temperatures, typical range, records and rainfall for "
f"{display} in {month_name}, from ~{year_range[1] - year_range[0]} years of local records."),
}
def month_page(request: Request, slug: str, month: str) -> Response:
if month not in MONTH_INDEX:
raise HTTPException(status_code=404, detail="Unknown month.")
city, cell, history = _resolve_city(slug)
return _respond_html(request, "month.html.j2", **_month_context(request, city, history, MONTH_INDEX[month]))
def _records_context(request, city, history) -> dict:
display = cities.display_name(city)
name = city["name"]
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
n_years = year_range[1] - year_range[0]
rec = grading.all_time_records(history)
rows = []
for key, label in METRIC_LABELS:
r = rec.get(key)
if not r:
continue
if key == "precip":
# "Record low" rain is meaningless (it's just 0), so the low side shows
# the longest dry streak and the date it began instead.
days, dry_start = grading.longest_dry_streak(history)
low = f"{days}-day dry spell" if days else ""
low_date = dry_start or ""
else:
low, low_date = _fmt(key, r["min"]), r["min_date"]
is_temp = key in _TEMP_METRICS
rows.append({
"label": label,
"high": _fmt(key, r["max"]), "high_date": r["max_date"],
"high_f": r["max"] if is_temp else None,
"low": low, "low_date": low_date,
"low_f": r["min"] if is_temp else None,
})
monthly = _monthly_records(history)
seasonal = _seasonal_records(history, city["lat"])
hemisphere = "Southern" if city["lat"] < 0 else "Northern"
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"),
(name, f"{BASE}/climate/{city['slug']}"), ("Records", None)]
o = origin(request)
page_url = f"{o}{BASE}/climate/{city['slug']}/records"
jsonld = {
"@context": "https://schema.org",
"@graph": [
{"@type": "Dataset",
"name": f"{display} monthly and seasonal weather records",
"description": f"Record high and low temperatures for {display} by month and by "
f"meteorological season, with the dates they occurred, from ~{n_years} "
f"years of daily climate history.",
"url": page_url,
"temporalCoverage": f"{year_range[0]}/{year_range[1]}",
"spatialCoverage": {"@type": "Place", "name": display,
"geo": {"@type": "GeoCoordinates",
"latitude": city["lat"], "longitude": city["lon"]}},
"creator": {"@type": "Organization", "name": "Thermograph"},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"},
_breadcrumb_jsonld(o, breadcrumb),
],
}
return {
"section": "climate", "city": city, "display": display, "name": name,
"year_range": year_range, "n_years": n_years,
"rows": rows, "monthly": monthly, "seasonal": seasonal,
"hemisphere": hemisphere, "all_time": rec,
"canonical_path": f"/climate/{city['slug']}/records",
"breadcrumb": breadcrumb,
"page_title": f"{display} weather records: monthly & seasonal record highs and lows",
"page_description": (f"Record high and low temperatures for {display} by month and by season, with the "
f"dates they occurred, plus all-time extremes — from ~{n_years} years of daily "
f"climate history."),
"jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")),
}
def records_page(request: Request, slug: str) -> Response:
city, cell, history = _resolve_city(slug)
return _respond_html(request, "records.html.j2", **_records_context(request, city, history))
# --- hub / glossary / about ---------------------------------------------------
def _breadcrumb(*items):
return list(items)
def hub_page(request: Request) -> Response:
groups = cities.by_country()
ctx = {
"section": "climate",
"groups": groups,
"n_cities": sum(len(v) for v in groups.values()),
"n_countries": len(groups),
"canonical_path": "/climate",
"breadcrumb": [("Home", f"{BASE}/"), ("Climate", None)],
"page_title": "City climate pages — averages, records and how today compares",
"page_description": ("Browse climate pages for hundreds of cities worldwide: average temperatures by "
"month, all-time records, and how today's weather compares to local history."),
}
return _respond_html(request, "hub.html.j2", **ctx)
# Weather-terms glossary. Each entry: slug -> (term, short definition, body HTML).
GLOSSARY: dict[str, dict] = {
"climate-normal": {
"term": "Climate normal",
"short": "The typical value of a weather metric for a place and time of year, averaged over decades.",
"body": "A <b>climate normal</b> is the long-term average of a weather variable (say, the daily high) "
"for a specific place and time of year. Thermograph builds each day's normal from every "
"historical day within &plusmn;7 days of that day-of-year, across ~45 years — so a day is judged "
"against its own season, not a single annual average.",
},
"percentile": {
"term": "Percentile",
"short": "Where a value ranks within a distribution — the 90th percentile is warmer than 90% of days.",
"body": "A <b>percentile</b> says where a value falls within a range of past values. If today's high is at "
"the 97th percentile, only about 3% of comparable days in this location's history were warmer. "
"Thermograph grades every day by its percentile against the local &plusmn;7-day seasonal distribution.",
},
"temperature-anomaly": {
"term": "Temperature anomaly",
"short": "How far a temperature departs from normal — the difference from the long-term average.",
"body": "A <b>temperature anomaly</b> is how much warmer or colder it is than the local normal for the "
"time of year. Thermograph expresses the same idea as a percentile and a grade (from “Below "
"Normal” to “Near Record”), so an anomaly is easy to read at a glance for any location.",
},
"feels-like": {
"term": "Feels-like temperature",
"short": "What the air actually feels like once humidity and wind are accounted for.",
"body": "<b>Feels-like</b> (apparent temperature) combines air temperature with humidity and wind. In heat "
"it uses the <a href=\"{base}/glossary/heat-index\">heat index</a>; in cold it uses "
"<a href=\"{base}/glossary/wind-chill\">wind chill</a>. Thermograph grades feels-like against its "
"own local history, so “Near Record” means extreme <i>for that place</i>.",
},
"heat-index": {
"term": "Heat index",
"short": "How hot it feels when humidity is factored into the air temperature.",
"body": "The <b>heat index</b> is the apparent temperature on a hot, humid day: high humidity slows sweat "
"evaporation, so it feels hotter than the thermometer reads. Thermograph folds it into the "
"<a href=\"{base}/glossary/feels-like\">feels-like</a> metric and grades how unusual it is locally.",
},
"wind-chill": {
"term": "Wind chill",
"short": "How cold it feels when wind is factored into the air temperature.",
"body": "<b>Wind chill</b> is the apparent temperature on a cold, windy day: wind strips away body heat, so "
"it feels colder than the air temperature. It's the cold-weather half of "
"<a href=\"{base}/glossary/feels-like\">feels-like</a>.",
},
"humidity": {
"term": "Absolute humidity",
"short": "The actual mass of water vapor in the air, in grams per cubic meter.",
"body": "Thermograph reports <b>absolute humidity</b> (g/m&sup3;) — the real amount of water vapor in the "
"air — rather than relative humidity, which shifts with temperature. Graded against local history, "
"it shows genuinely muggy or unusually dry days.",
},
"reanalysis": {
"term": "Reanalysis (ERA5)",
"short": "A gridded, physically-consistent record of past weather for anywhere on Earth.",
"body": "A <b>reanalysis</b> blends historical observations with a weather model to produce a consistent "
"record of past conditions everywhere — even where no station exists. Thermograph's ~45-year history "
"comes from ECMWF's <b>ERA5</b> reanalysis (via Open-Meteo), which is why it works for any point on Earth.",
},
}
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": "Weather &amp; climate glossary — heat index, feels-like, percentile, and more",
"page_description": ("Plain-language definitions of weather and climate terms: climate normal, percentile, "
"temperature anomaly, feels-like, heat index, wind chill, humidity, and reanalysis."),
}
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": "About Thermograph — how the weather grades are calculated",
"page_description": ("How Thermograph works: ~45 years of ERA5 climate history, a &plusmn;7-day seasonal "
"window, and empirical percentiles that grade each day relative to its own location."),
}
return _respond_html(request, "about.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)
# IndexNow ownership key, served as a text file at the site root (/{key}.txt)
# so Bing/DuckDuckGo/Yandex can verify our submissions. The key is fixed at
# startup, so registering its literal path here is safe.
import indexnow
_inkey = 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}/about", about_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)
# Hub before /climate/{slug} so the literal path wins.
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)
# Records before the {month} param so the literal path wins.
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)