"""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 contextlib import contextvars import datetime import functools import hashlib import json import math 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 from api import homepage from api import sitemap from api.payloads import OBS_COLS from data import cities from data import city_events from data import climate from data import grading from data import grid from web import content_loader import paths _BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") BASE = f"/{_BASE}" if _BASE else "" TEMPLATES_DIR = paths.TEMPLATES_DIR _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 (Dec–Feb is winter in the north, summer in the # south), so a city's latitude picks the label. SEASONS = [ ("Winter", "Summer", [12, 1, 2], "Dec–Feb"), ("Spring", "Autumn", [3, 4, 5], "Mar–May"), ("Summer", "Winter", [6, 7, 8], "Jun–Aug"), ("Autumn", "Spring", [9, 10, 11], "Sep–Nov"), ] # 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"} # Countries that actually use Fahrenheit. Mirrors F_REGIONS in frontend/units.js — # the client applies it to the visitor's locale, we apply it to the city's country. # A test asserts the two lists stay identical. F_COUNTRIES = frozenset({"US", "PR", "GU", "VI", "AS", "MP", "UM", "BS", "BZ", "KY", "PW", "FM", "MH", "LR"}) # One flag drives every measure: a °C page also gets mm and km/h. Mirrors the same # decision in frontend/units.js. MM_PER_IN = 25.4 KMH_PER_MPH = 1.609344 # The unit the current page renders temperatures in. None = °F, and also means # "this page has no city", which is what keeps the interactive pages on the # client-side locale default instead of being pinned server-side. # # A ContextVar rather than a parameter because _temp() is reached from both sides: # templates call it as a Jinja global, and the context builders call it directly # (the records tables are pre-rendered into strings in Python). Threading a `unit` # argument would mean touching ~20 call sites across three levels of nesting, where # forgetting one yields a silently wrong unit rather than an error. _UNIT: contextvars.ContextVar[str | None] = contextvars.ContextVar("display_unit", default=None) @contextlib.contextmanager def _unit_scope(unit: str | None): """Render temperatures in `unit` for the duration of the block. The reset is not optional: the page handlers are sync `def`, so Starlette runs them on a recycled threadpool thread, and a value left set would leak into whatever request that thread serves next — including pages with no city. """ token = _UNIT.set(unit) try: yield finally: _UNIT.reset(token) def unit_for_country(code: str | None) -> str: """The temperature unit a reader in this country expects.""" return "F" if (code or "").upper() in F_COUNTRIES else "C" def _round_half_up(x: float) -> int: """Round like JS's Math.round, not like Python's round(). Python rounds halves to even, JS rounds them up. Where they disagree the server would render one number and climate.js would repaint a different one — a visible flicker for exactly the visitor whose unit already matched. """ return math.floor(x + 0.5) def _c(f: float) -> int: return _round_half_up((f - 32) * 5 / 9) def _shown(f: float) -> int: """The number to print, in the active unit.""" return _c(f) if _UNIT.get() == "C" else _round_half_up(f) def _letter() -> str: return "C" if _UNIT.get() == "C" else "F" def _temp(f): """A Fahrenheit temperature as a client-convertible span, e.g. '72°F'. The text is rendered in the page's unit (the city's country convention — see _unit_scope), so crawlers and no-JS visitors get the local convention rather than a conversion they have to do themselves; climate.js repaints on toggle. data-temp-f stays Fahrenheit either way — it is the conversion source of truth. '—' for a missing value.""" if f is None: return "—" return Markup('{}°{}').format( f, _shown(f), _letter()) 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('{}°').format( f, _shown(f)) def _temp_text(f) -> str: """'72°F' as plain text. The span _temp() returns can't go in a , and a meta description is the one place a temperature is never converted client-side — it is what the search snippet quotes, which is why it has to be in the city's own unit at render time.""" if f is None: return "—" return f"{_shown(f)}°{_letter()}" def _precip(v): """Precipitation as a client-convertible span, the same contract as _temp(): text in the page's unit, data-precip-in always inches.""" if v is None: return "—" return Markup('{}').format( v, _precip_text(v)) def _precip_text(v) -> str: """Whole millimetres — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to.""" if v is None: return "—" return (f"{_round_half_up(v * MM_PER_IN)} mm" if _UNIT.get() == "C" else f"{v:.2f} in") def _wind(v): """Wind/gust as a client-convertible span; data-wind-mph is always mph.""" if v is None: return "—" return Markup('{}').format( v, _wind_text(v)) def _wind_text(v) -> str: if v is None: return "—" return (f"{_round_half_up(v * KMH_PER_MPH)} km/h" if _UNIT.get() == "C" else f"{_round_half_up(v)} mph") def _month_year(date_s: str) -> str: """'1995-07-13' -> 'Jul 1995'. Meta descriptions have ~155 characters to spend, so a record's date gives up its day.""" try: return datetime.date.fromisoformat(str(date_s)).strftime("%b %Y") except (ValueError, TypeError): return str(date_s) def _clamp_desc(text: str, limit: int = 155) -> str: """Meta descriptions are cut at ~155 characters in the SERP. Trim on a word boundary so we choose where the sentence ends rather than Google doing it.""" text = " ".join(text.split()) if len(text) <= limit: return text return text[:limit].rsplit(" ", 1)[0].rstrip(",;—-") + "…" def _titled(text: str, suffix: str = " · Thermograph", limit: int = 60) -> str: """Brand the title only when it costs nothing — the payload ("… in July", "hottest & coldest days") has to survive truncation first.""" return text + suffix if len(text) + len(suffix) <= limit else text def _fmt(metric: str, v) -> str: if v is None: return "—" if metric in _TEMP_METRICS: return _temp(v) if metric == "precip": return _precip(v) if metric == "humid": # Absolute humidity is g/m³ in both systems — there is no imperial unit for # it anyone would recognise — so it is never converted. return f"{v:.1f} g/m³" return _wind(v) # 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), } def _ordinal(n) -> str: """Percentile -> display ordinal. Delegates to grading so every surface (Day page, calendar, chart, city pages, homepage strip) agrees.""" return grading.pct_ordinal(n) _env.globals["temp_class"] = temp_class _env.globals["temp"] = _temp _env.globals["temp_bare"] = _temp_bare _env.filters["ordinal"] = _ordinal 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) # Taken from the same ContextVar the numbers were rendered through, so the # attribute units.js reads can't drift from what the page actually says. # None on any page without a city -> base.html.j2 omits it entirely. ctx.setdefault("unit_default", _UNIT.get()) 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) @functools.lru_cache(maxsize=1) def _content_lastmod() -> str: """A stable 'content last built' date for — 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.""" srcs = [paths.CITIES_JSON, __file__] mtimes = [os.path.getmtime(p) for p in srcs 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 = ['', ''] for path, cf, pr in sitemap.sitemap_entries(): parts.append( f"{base_url}{path}{lastmod}" f"{cf}{pr}" ) parts.append("") return Response("\n".join(parts), media_type="application/xml") def head_verify_html() -> Markup: """Search-engine ownership-verification tags, from env (empty when unset). Injected into every page's — 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('').format(google)) if bing: metas.append(Markup('').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": _precip(precip["mean"]) 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 (Dec–Feb 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) title = cities.title_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": _titled(f"{title} climate: daily normals, records & how unusual it is now"), "page_description": _clamp_desc( f"{title} averages highs of {_temp_text(warmest['high_f']) if warmest else '—'} in " f"{warmest['name'] if warmest else 'summer'} and lows of " f"{_temp_text(coldest['low_f']) if coldest else '—'} in " f"{coldest['name'] if coldest else 'winter'}. " f"Every day graded against {year_range[1] - year_range[0]} years of local 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) # The scope has to cover the context builder too, not just the render: it is # evaluated as an argument, so it runs first — and it is where most of the # page's temperatures are formatted. with _unit_scope(unit_for_country(city.get("country_code"))): 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) title = cities.title_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", _precip(precip["mean"]))) # 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": _precip(hi["total"]), "high_date": str(hi["yr"]), "high_tag": "Wettest", "low": _precip(lo["total"]), "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": _titled(f"{title} in {month_name}: normal weather & records"), "page_description": _clamp_desc( f"{title} averages {_temp_text(tmax['mean']) if tmax else '—'} highs and " f"{_temp_text(tmin['mean']) if tmin else '—'} lows in {month_name}. " f"Every day graded against {year_range[1] - year_range[0]} years of local history."), } 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) with _unit_scope(unit_for_country(city.get("country_code"))): 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) title = cities.title_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) # The two numbers the meta description leads with — the same all-time extremes # the page's own lede sentence quotes. hi_rec = (rec.get("tmax") or {}).get("max") hi_date = (rec.get("tmax") or {}).get("max_date") lo_rec = (rec.get("tmin") or {}).get("min") lo_date = (rec.get("tmin") or {}).get("min_date") 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": _titled( f"{title} weather records: hottest & coldest days since {year_range[0]}"), "page_description": _clamp_desc( f"{title}'s hottest day hit {_temp_text(hi_rec)}" f"{f' ({_month_year(hi_date)})' if hi_date else ''}; its coldest fell to " f"{_temp_text(lo_rec)}{f' ({_month_year(lo_date)})' if lo_date else ''}. " f"Every day graded against {n_years} years of local history."), "jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")), } def records_page(request: Request, slug: str) -> Response: city, cell, history = _resolve_city(slug) with _unit_scope(unit_for_country(city.get("country_code"))): 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": PAGES["hub"]["title"], "page_description": PAGES["hub"]["description"], } return _respond_html(request, "hub.html.j2", **ctx) # Weather-terms glossary, loaded from content/glossary.yaml (see content_loader.py). GLOSSARY: dict[str, dict] = content_loader.load_glossary() # Static-page SEO title/description, loaded from content/pages.yaml. The # per-city/per-month pages build theirs dynamically from city data instead — # see their own page_title/page_description assignments above. 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 ----------------------------------------------------------------- # The homepage is the Weekly tool plus the distribution surfaces around it. It is # server-rendered like the SEO pages (rather than a static file with placeholder # substitution) so a cold visitor with no JS still gets the headline, a real # graded example, the records strip and the city links. _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."), } # 12 city chips, chosen for geographic spread rather than raw population, so the # strip reads as "anywhere on Earth" and seeds crawl paths across the hub. HOME_CITY_SLUGS = ( "new-york-city-new-york-us", "london-england-gb", "tokyo-jp", "sydney-new-south-wales-au", "sao-paulo-br", "lagos-ng", "mumbai-maharashtra-in", "mexico-city-mx", "cairo-eg", "toronto-ontario-ca", "berlin-state-of-berlin-de", "seattle-washington-us", ) def _home_cities() -> list[dict]: """The chip set, skipping any slug not in the routable city list so a regenerated cities.json can never 404 a homepage link.""" out = [] for slug in HOME_CITY_SLUGS: city = cities.get(slug) if city: out.append({"slug": slug, "name": city["name"]}) return out def home_page(request: Request) -> Response: feed = homepage.load() stale = bool(feed) and homepage.is_stale(feed) unusual = None ranked: list = [] if feed: ranked = feed.get("ranked") or [] pick = (feed.get("picks") or {}).get("extreme") if pick: unusual = dict(pick, is_default=True) ctx = { "section": "home", # The hero headline owns the page's sole h1, so the brand degrades to a

. "brand_tag": "p", # The tool needs the wide app column, not the 880px reading column. "main_class": "", "canonical_path": "/", "unusual": unusual, "stale": stale, "ranked": ranked, "cities": _home_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) # 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, ) # The homepage. Registered here (not as a static file in app.py) so it is # server-rendered from the same Jinja environment as the SEO pages. 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) # 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)