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:
parent
2f289f1cb6
commit
58ac3120b2
17 changed files with 6099 additions and 0 deletions
6
app.py
6
app.py
|
|
@ -18,6 +18,7 @@ from fastapi.staticfiles import StaticFiles
|
||||||
import api_accounts
|
import api_accounts
|
||||||
import audit
|
import audit
|
||||||
import climate
|
import climate
|
||||||
|
import content
|
||||||
import db
|
import db
|
||||||
import grid
|
import grid
|
||||||
import notify
|
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}/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)
|
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.
|
# 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
|
# 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
|
# "/", so at the root (BASE == "") mount at "/" — the earlier routes still win
|
||||||
|
|
|
||||||
5002
cities.json
Normal file
5002
cities.json
Normal file
File diff suppressed because it is too large
Load diff
54
cities.py
Normal file
54
cities.py
Normal 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
506
content.py
Normal 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 ±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 ±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³) — 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 & 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 ±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
82
gen_cities.py
Normal 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()
|
||||||
21
grading.py
21
grading.py
|
|
@ -161,6 +161,27 @@ def climatology(df: pl.DataFrame, target_doy: int) -> dict:
|
||||||
return out
|
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:
|
def _band_stats(samples: np.ndarray) -> dict | None:
|
||||||
if samples.size == 0:
|
if samples.size == 0:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,5 @@ aiosqlite==0.22.1
|
||||||
# Web Push (VAPID) delivery of notifications (see backend/push.py). Pulls in
|
# Web Push (VAPID) delivery of notifications (see backend/push.py). Pulls in
|
||||||
# py-vapid, cryptography, and http-ece.
|
# py-vapid, cryptography, and http-ece.
|
||||||
pywebpush==2.0.0
|
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
36
templates/about.html.j2
Normal 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 sq 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>±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°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
58
templates/base.html.j2
Normal 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 & 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
75
templates/city.html.j2
Normal 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 %}
|
||||||
19
templates/glossary.html.j2
Normal file
19
templates/glossary.html.j2
Normal 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 & 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 %}
|
||||||
21
templates/glossary_term.html.j2
Normal file
21
templates/glossary_term.html.j2
Normal 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
23
templates/hub.html.j2
Normal 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
39
templates/month.html.j2
Normal 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
33
templates/records.html.j2
Normal 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
74
tests/test_content.py
Normal 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
48
warm_cities.py
Normal 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)
|
||||||
Loading…
Reference in a new issue