thermograph/cities.py
Emi Griffith 1720ac1da6 Lead climate page titles with the city and the payload (#188)
Search Console's first 48 hours showed city-month pages drawing impressions at
positions 44-77 and converting almost none of them: the title template spent its
width on region and country ("Average weather in London, England, United Kingdom
in July"), so Google cut it before the month a searcher was looking for.

Titles now lead with the bare city name and put the subject next, which keeps
both inside the first ~40 characters:

  London in July: normal weather & records
  London climate: daily normals, records & how unusual it is now
  London weather records: hottest & coldest days since 1980

Add cities.title_name() for the short form. 968 of the 1000 cities have a unique
name and render bare; the 32 that don't are qualified with their country code
("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur —
recur *within* one country, where the country code collides too and would hand
two different pages the same title, so those fall back to admin1 ("Columbus,
Ohio"). A test asserts every rendered title is unique across the set.

Meta descriptions now lead with the page's actual numbers rather than restating
the template ("London averages highs of 73°F in July and lows of 38°F in
January."), capped at the ~155 characters a SERP shows.

The records title derives its year from the loaded history rather than a fixed
date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are
unchanged; og:title and og:description follow <title>/<meta> automatically
through base.html.j2.

Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00

102 lines
3.6 KiB
Python

"""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")
_FLAVOR_PATH = os.path.join(os.path.dirname(__file__), "cities_flavor.json")
_CITIES: list[dict] | None = None
_BY_SLUG: dict[str, dict] | None = None
_FLAVOR: dict[str, dict] | None = None
_DUPES: dict[str, dict[str, int]] | 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 flavor(slug: str) -> dict | None:
"""A city's descriptive blurb {extract, url, title} from cities_flavor.json, or
None when we have no confident match (the page renders fine without it)."""
global _FLAVOR
if _FLAVOR is None:
try:
with open(_FLAVOR_PATH, encoding="utf-8") as f:
_FLAVOR = json.load(f)
except (OSError, ValueError):
_FLAVOR = {}
return _FLAVOR.get(slug)
def title_name(city: dict) -> str:
"""The shortest label that still names this city unambiguously — for page
titles, where display_name()'s region + country spend the width Google gives
us before it ever reaches the payload ("… in July").
'Seattle' for the 968 cities whose name is unique; 'London, GB' when the name
recurs in another country; 'Columbus, Ohio' when it recurs *within* one
country, where the country code would collide too and hand two different
pages the same title.
"""
name = city["name"]
dupes = _dupe_names().get(name)
if not dupes:
return name
if dupes.get(city.get("country_code")) == 1:
return f"{name}, {city['country_code']}"
return f"{name}, {city['admin1']}" if city.get("admin1") else name
def _dupe_names() -> dict[str, dict[str, int]]:
"""{name: {country_code: how many cities share both}} for duplicated names
only. Built once alongside the city list."""
global _DUPES
if _DUPES is None:
counts: dict[str, dict[str, int]] = {}
for c in _load():
counts.setdefault(c["name"], {})
cc = c.get("country_code")
counts[c["name"]][cc] = counts[c["name"]].get(cc, 0) + 1
_DUPES = {n: by_cc for n, by_cc in counts.items() if sum(by_cc.values()) > 1}
return _DUPES
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, both the countries and the cities within each
ordered alphabetically — for the /climate hub's crawlable link graph + search."""
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["name"].lower())
return dict(sorted(groups.items(), key=lambda kv: kv[0].lower()))