thermograph/cities.py

55 lines
1.7 KiB
Python
Raw Normal View History

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.
2026-07-15 23:53:11 +00:00
"""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"]))