SEO: crawlable programmatic climate pages + technical hygiene (#96)

* SEO: generate curated city set for crawlable climate pages

gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.

* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene

- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
  (disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
  the home/static pages plus every city, month, and records URL from cities.py).
  Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
  referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
  link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
  self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.

* SEO: server-rendered per-city climate page (/climate/{slug})

The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).

* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages

Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).

* SEO: climate hub, weather glossary, and about/methodology pages

- /climate: crawlable directory of all ~500 cities grouped by country — the
  internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
  percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
  reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
  percentile grading) for E-E-A-T. All linked from the shared footer.

* SEO: archive warmer, content-page tests, and deploy docs

- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
  cell's archive so /climate pages serve from cache and a crawl can't burst the
  archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
  enumerating city/month/records URLs, and that a rendered city page carries the
  stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
  about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
This commit is contained in:
Emi Griffith 2026-07-15 16:53:11 -07:00 committed by GitHub
parent 2f289f1cb6
commit 58ac3120b2
17 changed files with 6099 additions and 0 deletions

6
app.py
View file

@ -18,6 +18,7 @@ from fastapi.staticfiles import StaticFiles
import api_accounts
import audit
import climate
import content
import db
import grid
import notify
@ -617,6 +618,11 @@ app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEA
app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/alerts", _page("subscriptions.html"), methods=["GET", "HEAD"], include_in_schema=False)
# Crawlable, server-rendered SEO pages (climate hub / per-city / month / records /
# glossary / about) + robots.txt + sitemap.xml. Registered before the static mount
# so these explicit routes win.
content.register(app)
# Everything else under BASE (app.js, style.css, nav.js, …) is a static asset.
# Registered last so the explicit page routes above win. Mounts must start with
# "/", so at the root (BASE == "") mount at "/" — the earlier routes still win

5002
cities.json Normal file

File diff suppressed because it is too large Load diff

54
cities.py Normal file
View file

@ -0,0 +1,54 @@
"""Access to the curated city set (backend/cities.json) that gets crawlable
climate pages. Loaded once, lazily; regenerate the JSON with gen_cities.py."""
import json
import os
_PATH = os.path.join(os.path.dirname(__file__), "cities.json")
_CITIES: list[dict] | None = None
_BY_SLUG: dict[str, dict] | None = None
def _load() -> list[dict]:
global _CITIES, _BY_SLUG
if _CITIES is None:
with open(_PATH, encoding="utf-8") as f:
_CITIES = json.load(f)
_BY_SLUG = {c["slug"]: c for c in _CITIES}
return _CITIES
def all_cities() -> list[dict]:
return _load()
def all_slugs() -> list[str]:
return [c["slug"] for c in _load()]
def get(slug: str) -> dict | None:
"""The city for a slug, or None (→ 404)."""
_load()
return _BY_SLUG.get(slug)
def display_name(city: dict) -> str:
"""Human label: 'Seattle, Washington, United States' (drops repeated admin1)."""
parts = [city["name"]]
if city.get("admin1") and city["admin1"] != city["name"]:
parts.append(city["admin1"])
if city.get("country"):
parts.append(city["country"])
return ", ".join(parts)
def by_country() -> dict[str, list[dict]]:
"""Cities grouped by country (population-descending within each), country keys
ordered by their largest city for the /climate hub's crawlable link graph."""
groups: dict[str, list[dict]] = {}
for c in _load():
key = c.get("country") or c.get("country_code") or "Other"
groups.setdefault(key, []).append(c)
for v in groups.values():
v.sort(key=lambda x: -x["population"])
# order countries by their biggest city's population (most prominent first)
return dict(sorted(groups.items(), key=lambda kv: -kv[1][0]["population"]))

506
content.py Normal file
View file

@ -0,0 +1,506 @@
"""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 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
import cities
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)}
# 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) -> str:
"""A Fahrenheit value shown in both units: '72°F (22°C)'."""
if f is None:
return ""
return f"{round(f)}°F ({_c(f)}°C)"
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
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 _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_xml(request: Request) -> Response:
base_url = f"{origin(request)}{BASE}"
today = datetime.date.today().isoformat()
urls: list[tuple[str, str, str]] = [] # (loc, changefreq, priority)
def add(path: str, changefreq: str, priority: str) -> None:
urls.append((f"{base_url}{path}", changefreq, priority))
add("/", "daily", "1.0")
for p in ("/climate", "/about", "/glossary", "/calendar", "/compare", "/legend"):
add(p, "weekly", "0.6")
for slug in cities.all_slugs():
add(f"/climate/{slug}", "daily", "0.8")
add(f"/climate/{slug}/records", "monthly", "0.5")
for m in MONTHS:
add(f"/climate/{slug}/{m}", "monthly", "0.5")
parts = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for loc, cf, pr in urls:
parts.append(
f"<url><loc>{loc}</loc><lastmod>{today}</lastmod>"
f"<changefreq>{cf}</changefreq><priority>{pr}</priority></url>"
)
parts.append("</urlset>")
return Response("\n".join(parts), media_type="application/xml")
# --- 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")
rows.append({
"name": name,
"slug": MONTHS[i - 1],
"high": _temp(tmax["mean"]) if tmax else "",
"high_f": tmax["mean"] if tmax else None,
"low": _temp(tmin["mean"]) if tmin else "",
"low_f": tmin["mean"] if tmin else None,
"precip": f"{precip['mean']:.2f} in" if precip else "",
"precip_v": precip["mean"] if precip else None,
})
return rows
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}"
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)"},
{"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": nm,
**({"item": f"{o}{href}"} if href else {})}
for i, (nm, href) in enumerate(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,
"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", f"{_temp(tmax['p10'])} to {_temp(tmax['p90'])}"))
if tmin:
stats.append(("Average low", _temp(tmin["mean"])))
stats.append(("Typical low range", f"{_temp(tmin['p10'])} to {_temp(tmin['p90'])}"))
if precip:
stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in"))
records = []
if mrec.get("tmax"):
records.append(("Warmest on record", _temp(mrec["tmax"]["max"]), mrec["tmax"]["max_date"]))
if mrec.get("tmin"):
records.append(("Coldest on record", _temp(mrec["tmin"]["min"]), mrec["tmin"]["min_date"]))
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 "",
"stats": stats, "records": records,
"tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}",
"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())]
rec = grading.all_time_records(history)
rows = []
for key, label in METRIC_LABELS:
r = rec.get(key)
if not r:
continue
rows.append({
"label": label,
"high": _fmt(key, r["max"]), "high_date": r["max_date"],
"low": _fmt(key, r["min"]), "low_date": r["min_date"],
})
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"),
(name, f"{BASE}/climate/{city['slug']}"), ("Records", None)]
return {
"section": "climate", "city": city, "display": display, "name": name,
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"rows": rows,
"canonical_path": f"/climate/{city['slug']}/records",
"breadcrumb": breadcrumb,
"page_title": f"{display} weather records: hottest and coldest days on record",
"page_description": (f"All-time record high and low temperatures (and the dates they occurred) for "
f"{display}, from ~{year_range[1] - year_range[0]} years of daily climate history."),
}
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)
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)

82
gen_cities.py Normal file
View file

@ -0,0 +1,82 @@
"""Offline generator for backend/cities.json — the finite set of cities that get
crawlable climate pages (/climate/<slug>). Run occasionally to refresh the list:
python gen_cities.py [N] # default N=500 top metros by population
It reuses the GeoNames index that places.py already downloads/parses (calling
places._load() synchronously fills places._data), takes the top-N places by
population, and assigns each a stable, unique, URL-safe slug. Committing the output
keeps the routable city set explicit and reviewable, and decouples page-serving
from the async place-name loader.
"""
import json
import os
import re
import sys
import unicodedata
import places
OUT_PATH = os.path.join(os.path.dirname(__file__), "cities.json")
# GeoNames entry tuple layout (see places._load): the fields we keep.
_NAME, _ADMIN1, _COUNTRY, _CC, _LAT, _LON, _POP = 1, 2, 3, 4, 5, 6, 7
def slugify(*parts: str) -> str:
"""ASCII, lowercase, hyphenated slug from name/admin/country parts."""
text = " ".join(p for p in parts if p)
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
return re.sub(r"-{2,}", "-", text)
def build(n: int = 500) -> list[dict]:
places._load() # synchronous parse; fills places._data (entries are pop-desc)
if not places._data:
raise SystemExit("GeoNames index failed to load (see logs); cannot generate cities.")
entries = places._data[0]
out: list[dict] = []
seen_slugs: set[str] = set()
for e in entries:
if len(out) >= n:
break
name, admin1, country, cc = e[_NAME], e[_ADMIN1], e[_COUNTRY], e[_CC]
# Drop admin1 from the slug when it just repeats the city name
# (e.g. Tokyo/Tokyo, Singapore/Singapore) to avoid "tokyo-tokyo-jp".
admin_part = admin1 if admin1 and slugify(admin1) != slugify(name) else ""
base = slugify(name, admin_part, cc or "")
if not base:
continue
slug = base
i = 2
while slug in seen_slugs: # disambiguate the rare collision
slug = f"{base}-{i}"
i += 1
seen_slugs.add(slug)
out.append({
"slug": slug,
"name": name,
"admin1": admin1,
"country": country,
"country_code": cc,
"lat": round(e[_LAT], 5),
"lon": round(e[_LON], 5),
"population": e[_POP],
})
return out
def main() -> None:
n = int(sys.argv[1]) if len(sys.argv) > 1 else 500
cities = build(n)
with open(OUT_PATH, "w", encoding="utf-8") as f:
json.dump(cities, f, ensure_ascii=False, indent=0, separators=(",", ":"))
f.write("\n")
print(f"wrote {len(cities)} cities -> {OUT_PATH}")
print("sample:", ", ".join(c["slug"] for c in cities[:8]))
if __name__ == "__main__":
main()

View file

@ -161,6 +161,27 @@ def climatology(df: pl.DataFrame, target_doy: int) -> dict:
return out
def all_time_records(df: pl.DataFrame) -> dict:
"""All-time record high/low (and the date each occurred) per metric across the
full archive the raw material for the records page and the city teaser."""
out: dict = {}
for var in CLIMO_METRICS:
if var not in df.columns:
continue
sub = df.select(["date", var]).drop_nulls(var)
if sub.is_empty():
continue
hi = sub.row(int(sub[var].arg_max()), named=True)
lo = sub.row(int(sub[var].arg_min()), named=True)
out[var] = {
"max": round(float(hi[var]), 2),
"max_date": hi["date"].isoformat() if hasattr(hi["date"], "isoformat") else str(hi["date"]),
"min": round(float(lo[var]), 2),
"min_date": lo["date"].isoformat() if hasattr(lo["date"], "isoformat") else str(lo["date"]),
}
return out
def _band_stats(samples: np.ndarray) -> dict | None:
if samples.size == 0:
return None

View file

@ -9,3 +9,5 @@ aiosqlite==0.22.1
# Web Push (VAPID) delivery of notifications (see backend/push.py). Pulls in
# py-vapid, cryptography, and http-ece.
pywebpush==2.0.0
# Server-rendered SEO content pages (see backend/content.py, templates/).
jinja2==3.1.6

36
templates/about.html.j2 Normal file
View file

@ -0,0 +1,36 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>About Thermograph</h1>
<p class="lede">Thermograph answers one question for any point on Earth: <b>how unusual is this
weather?</b> Instead of an absolute temperature, it shows where a day falls in that exact
location's own climate history.</p>
<h2>Where the data comes from</h2>
<p>The ~45-year daily record — high, low, feels-like, humidity, wind, gust and precipitation —
comes from the <b>ERA5 reanalysis</b> (ECMWF), served via <a href="https://open-meteo.com/" rel="nofollow">Open-Meteo</a>.
Because reanalysis is a gridded, model-consistent record, Thermograph works even where there's
no weather station — anywhere on the ~4&nbsp;sq&nbsp;mi grid.</p>
<h2>How a day is graded</h2>
<p>For each metric and date, Thermograph builds a reference distribution from every historical day
within <b>&plusmn;7 days of that day-of-year</b> — a 15-day seasonal window across all years. The
observed value is placed on that distribution as an <a href="{{ base }}/glossary/percentile">empirical
percentile</a>, then mapped to a grade from “Below Normal” through “High” to “Near Record”.</p>
<p>Everything is <b>relative to the location</b>: a 60&deg;F day can be “Above Normal” in one place and
“Below Normal” in another. Precipitation is graded only among days that actually rained, so “Heavy”
means heavy for a rainy day <i>here</i>. See the <a href="{{ base }}/legend">grade guide</a> for the full scale.</p>
<h2>Freshness</h2>
<p>The historical archive updates as new days are added; recent and forecast conditions refresh hourly.
City climate pages show the latest recorded day graded against the local normal.</p>
<p><a class="cta" href="{{ base }}/">Open the weather grader →</a></p>
</article>
{% endblock %}

58
templates/base.html.j2 Normal file
View file

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Thermograph{% endblock %}</title>
<meta name="description" content="{% block description %}{% endblock %}" />
<meta name="theme-color" content="#f0803c" />
<link rel="canonical" href="{{ base_url }}{% block canonical_path %}/{% endblock %}" />
<meta property="og:type" content="{% block og_type %}website{% endblock %}" />
<meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="{{ self.title() }}" />
<meta property="og:description" content="{{ self.description() }}" />
<meta property="og:url" content="{{ base_url }}{{ self.canonical_path() }}" />
<meta property="og:image" content="{{ base_url }}/logo.png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Thermograph logo" />
<meta name="twitter:card" content="summary" />
<link rel="icon" href="{{ base }}/logo.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="{{ base }}/logo.png" />
<link rel="manifest" href="{{ base }}/manifest.webmanifest" />
<link rel="stylesheet" href="{{ base }}/style.css" />
{% block jsonld %}{% endblock %}
</head>
<body>
<header>
<div class="brand">
<a class="logo" href="{{ base }}/" aria-label="Thermograph home">▚</a>
<div>
<h1 class="site-name"><a href="{{ base }}/">Thermograph</a></h1>
<p class="tag">How unusual is the weather? Graded against ~45 years of local climate history.</p>
</div>
<nav class="view-nav" aria-label="Views">
<a href="{{ base }}/">Weekly</a>
<a href="{{ base }}/calendar">Calendar</a>
<a href="{{ base }}/compare">Compare</a>
<a href="{{ base }}/climate"{% if section == 'climate' %} class="active"{% endif %}>Climate</a>
</nav>
</div>
</header>
<main class="content">
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<nav aria-label="Footer">
<a href="{{ base }}/climate">City climates</a>
<a href="{{ base }}/glossary">Weather glossary</a>
<a href="{{ base }}/about">About &amp; methodology</a>
<a href="{{ base }}/">Open the tool</a>
</nav>
<p class="muted">Thermograph grades weather by its percentile against ~45 years of local
climate history. Climate data via Open-Meteo (ERA5 reanalysis).</p>
</footer>
</body>
</html>

75
templates/city.html.j2 Normal file
View file

@ -0,0 +1,75 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block jsonld %}<script type="application/ld+json">{{ jsonld_str | safe }}</script>{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>{{ display }} climate</h1>
<p class="lede">Average temperatures, precipitation and all-time records for <b>{{ display }}</b>,
with every day graded against ~{{ n_years }} years of local climate history
({{ year_range[0] }}{{ year_range[1] }}). See where {% if warmest %}the warmest month is
<b>{{ warmest.name }}</b> ({{ warmest.high }} average high){% endif %}{% if coldest %} and the coldest
is <b>{{ coldest.name }}</b> ({{ coldest.low }} average low){% endif %}.</p>
{% if today %}
<section class="climate-today">
<h2>How today compares</h2>
<p class="muted">Latest recorded day: {{ today.date }} — each reading placed on {{ display }}'s
own ±7-day seasonal distribution.</p>
<div class="today-cards">
{% for c in today.cards %}
<div class="today-card" style="border-left-color: var(--{{ c.cls }})">
<span class="tc-label">{{ c.label }}</span>
<span class="tc-value">{{ c.value }}</span>
{% if c.percentile is not none %}<span class="tc-grade">{{ c.grade }} · {{ c.percentile }}th pct</span>
{% else %}<span class="tc-grade">{{ c.grade }}</span>{% endif %}
</div>
{% endfor %}
</div>
<p><a class="cta" href="{{ base }}/#{{ tool_hash }}">Open {{ name }} in the live weather grader →</a></p>
</section>
{% endif %}
<section class="climate-normals">
<h2>{{ name }} average temperatures by month</h2>
<p>Typical daily high and low, and average precipitation, for each month in {{ display }},
based on {{ year_range[0] }}{{ year_range[1] }} records.
{% if wettest %}The wettest month is <b>{{ wettest.name }}</b> ({{ wettest.precip }} on an average day).{% endif %}</p>
<div class="table-wrap">
<table class="normals-table">
<thead><tr><th>Month</th><th>Avg high</th><th>Avg low</th><th>Avg precip</th></tr></thead>
<tbody>
{% for m in months %}
<tr>
<td><a href="{{ base }}/climate/{{ city.slug }}/{{ m.slug }}">{{ m.name }}</a></td>
<td>{{ m.high }}</td><td>{{ m.low }}</td><td>{{ m.precip }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% if records.tmax or records.tmin %}
<section class="climate-records">
<h2>Record extremes</h2>
<ul>
{% if records.tmax %}<li><b>Hottest day on record:</b> {{ records.tmax.max | round | int }}°F
({{ ((records.tmax.max - 32) * 5 / 9) | round | int }}°C) on {{ records.tmax.max_date }}</li>{% endif %}
{% if records.tmin %}<li><b>Coldest day on record:</b> {{ records.tmin.min | round | int }}°F
({{ ((records.tmin.min - 32) * 5 / 9) | round | int }}°C) on {{ records.tmin.min_date }}</li>{% endif %}
</ul>
<p><a href="{{ base }}/climate/{{ city.slug }}/records">All {{ name }} weather records →</a></p>
</section>
{% endif %}
<p class="climate-foot muted">Percentiles compare each day to {{ display }}'s own history, so grades are
relative to this location — a “Near Record” day here isn't the same temperature as elsewhere.
<a href="{{ base }}/legend">How the grades work →</a></p>
</article>
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>Weather &amp; climate glossary</h1>
<p class="lede">Plain-language definitions of the terms Thermograph uses to grade the weather.</p>
{% for t in terms %}
<div class="glossary-term">
<h2><a href="{{ base }}/glossary/{{ t.slug }}">{{ t.term }}</a></h2>
<p>{{ t.short }}</p>
</div>
{% endfor %}
</article>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block jsonld %}<script type="application/ld+json">{"@context":"https://schema.org","@type":"DefinedTerm","name":{{ term | tojson }},"description":{{ page_description | tojson }},"inDefinedTermSet":"{{ base_url }}/glossary"}</script>{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>{{ term }}</h1>
<p class="lede">{{ body | safe }}</p>
<p><a class="cta" href="{{ base }}/">See it live — grade any location's weather →</a></p>
<div class="climate-foot">
<h2>More terms</h2>
<ul class="city-links">
{% for o in others %}<li><a href="{{ base }}/glossary/{{ o.slug }}">{{ o.term }}</a></li>{% endfor %}
</ul>
</div>
</article>
{% endblock %}

23
templates/hub.html.j2 Normal file
View file

@ -0,0 +1,23 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>City climates</h1>
<p class="lede">Average temperatures by month, all-time records, and how today compares — for
{{ n_cities }} cities across {{ n_countries }} countries. Every day is graded against that
location's own ~45 years of climate history.</p>
{% for country, cs in groups.items() %}
<section class="hub-country">
<h2>{{ country }}</h2>
<ul class="city-links">
{% for c in cs %}<li><a href="{{ base }}/climate/{{ c.slug }}">{{ c.name }}</a></li>{% endfor %}
</ul>
</section>
{% endfor %}
</article>
{% endblock %}

39
templates/month.html.j2 Normal file
View file

@ -0,0 +1,39 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>Weather in {{ display }} in {{ month_name }}</h1>
<p class="lede">In an average {{ month_name }}, {{ name }} sees a daily high around <b>{{ avg_high }}</b>
and a low around <b>{{ avg_low }}</b>, based on {{ year_range[0] }}{{ year_range[1] }} of local
climate records.</p>
<table class="normals-table" style="max-width:520px">
<tbody>
{% for label, value in stats %}
<tr><th style="width:55%">{{ label }}</th><td>{{ value }}</td></tr>
{% endfor %}
</tbody>
</table>
{% if records %}
<h2>{{ month_name }} records</h2>
<ul class="climate-records">
{% for label, value, date in records %}<li><b>{{ label }}:</b> {{ value }} on {{ date }}</li>{% endfor %}
</ul>
{% endif %}
<p><a class="cta" href="{{ base }}/#{{ tool_hash }}">See {{ name }}'s live weather grade →</a></p>
<p class="climate-foot muted">
<a href="{{ base }}/climate/{{ city.slug }}/{{ prev.slug }}"> {{ prev.name }}</a> ·
<a href="{{ base }}/climate/{{ city.slug }}">{{ name }} climate overview</a> ·
<a href="{{ base }}/climate/{{ city.slug }}/{{ next.slug }}">{{ next.name }} </a>
</p>
</article>
{% endblock %}

33
templates/records.html.j2 Normal file
View file

@ -0,0 +1,33 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>{{ display }} weather records</h1>
<p class="lede">All-time record highs and lows for {{ display }}, and the dates they occurred, across
{{ year_range[0] }}{{ year_range[1] }} of daily climate history.</p>
<div class="table-wrap">
<table class="normals-table">
<thead><tr><th>Metric</th><th>Record high</th><th>On</th><th>Record low</th><th>On</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.label }}</td>
<td>{{ r.high }}</td><td>{{ r.high_date }}</td>
<td>{{ r.low }}</td><td>{{ r.low_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p><a class="cta" href="{{ base }}/#{{ '%.5f,%.5f' | format(city.lat, city.lon) }}">Grade {{ name }}'s weather now →</a></p>
<p class="climate-foot muted"><a href="{{ base }}/climate/{{ city.slug }}"> Back to {{ name }} climate</a></p>
</article>
{% endblock %}

74
tests/test_content.py Normal file
View file

@ -0,0 +1,74 @@
"""Tests for the crawlable SEO content pages: the city set, robots/sitemap, and
that the server-rendered pages contain the stats in the HTML (with the weather
layer faked, like test_api.py)."""
import pytest
from fastapi.testclient import TestClient
import app as appmod
import cities
import climate
# BASE defaults to /thermograph in tests (THERMOGRAPH_BASE unset).
B = "/thermograph"
SLUG = "london-england-gb" # present in cities.json
@pytest.fixture
def client(monkeypatch, history, recent):
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.clone())
monkeypatch.setattr(climate, "get_history", lambda cell: (history.clone(), {"cached": True}))
return TestClient(appmod.app)
def test_city_set_slugs_unique_and_lookup():
slugs = cities.all_slugs()
assert len(slugs) == len(set(slugs)) >= 100
assert cities.get(SLUG) is not None
assert cities.get("does-not-exist") is None
def test_robots_txt(client):
r = client.get(f"{B}/robots.txt")
assert r.status_code == 200
assert "Sitemap:" in r.text and "/sitemap.xml" in r.text
assert "Disallow: /thermograph/api/" in r.text
def test_sitemap_lists_city_urls(client):
r = client.get(f"{B}/sitemap.xml")
assert r.status_code == 200
assert "<urlset" in r.text
assert f"/climate/{SLUG}</loc>" in r.text
assert f"/climate/{SLUG}/july</loc>" in r.text
assert f"/climate/{SLUG}/records</loc>" in r.text
def test_city_page_renders_stats_in_html(client):
r = client.get(f"{B}/climate/{SLUG}")
assert r.status_code == 200
b = r.text
assert "London" in b and "climate" in b.lower()
assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b
assert '"@type":"Dataset"' in b
assert "average temperatures by month" in b.lower()
# a real number rendered (°F appears in the normals/today text)
assert "°F" in b
def test_city_404(client):
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
def test_month_and_records(client):
assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200
assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200
assert client.get(f"{B}/climate/{SLUG}/notamonth").status_code == 404
def test_hub_glossary_about(client):
assert client.get(f"{B}/climate").status_code == 200
assert client.get(f"{B}/glossary").status_code == 200
assert client.get(f"{B}/glossary/percentile").status_code == 200
assert client.get(f"{B}/glossary/not-a-term").status_code == 404
assert client.get(f"{B}/about").status_code == 200

48
warm_cities.py Normal file
View file

@ -0,0 +1,48 @@
"""Pre-warm the archives for the curated city set (backend/cities.json) so the
crawlable /climate pages render from cache and a search-engine crawl never bursts
the archive API quota. Run at/after deploy:
python warm_cities.py [--limit N] [--pace SECONDS]
Idempotent: a cell whose archive is already cached is skipped. Fetches are paced
(default 2s) to stay well under the archive API's rate limit. A cell that still
has no cached archive when its page is first requested self-heals via get_history,
so this is an optimization, not a hard dependency.
"""
import sys
import time
import cities
import climate
import grid
def main(limit: int | None = None, pace: float = 2.0) -> None:
todo = cities.all_cities()
if limit:
todo = todo[:limit]
fetched = skipped = failed = 0
for i, c in enumerate(todo, 1):
cell = grid.snap(c["lat"], c["lon"])
cached = climate.load_cached_history(cell)
if cached is not None and not cached.is_empty():
skipped += 1
continue
try:
climate.get_history(cell) # fetch + cache the ~45-yr archive
climate.get_recent_forecast(cell) # + the recent/forecast bundle (today block)
fetched += 1
print(f"[{i}/{len(todo)}] warmed {c['slug']} ({cell['id']})")
time.sleep(pace)
except Exception as e: # noqa: BLE001 - keep going; the page self-heals later
failed += 1
print(f"[{i}/{len(todo)}] FAILED {c['slug']}: {e}")
time.sleep(pace)
print(f"done: fetched={fetched} skipped(cached)={skipped} failed={failed}")
if __name__ == "__main__":
args = sys.argv[1:]
lim = int(args[args.index("--limit") + 1]) if "--limit" in args else None
pc = float(args[args.index("--pace") + 1]) if "--pace" in args else 2.0
main(limit=lim, pace=pc)