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
This commit is contained in:
Emi Griffith 2026-07-19 11:17:13 -07:00 committed by GitHub
parent 7d17d8bc4f
commit 1720ac1da6
3 changed files with 153 additions and 11 deletions

View file

@ -8,6 +8,7 @@ _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]:
@ -46,6 +47,39 @@ def flavor(slug: str) -> dict | None:
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"]]

View file

@ -82,6 +82,39 @@ def _temp_bare(f):
return Markup('<span class="temp" data-temp-f="{:.1f}" data-bare>{}°</span>').format(f, round(f))
def _temp_text(f) -> str:
"""'72°F' as plain text. The span _temp() returns can't go in a
<meta content="">, and a meta description is the one place a temperature is
never converted client-side it is what the search snippet quotes."""
if f is None:
return ""
return f"{round(f)}°F"
def _month_year(date_s: str) -> str:
"""'1995-07-13' -> 'Jul 1995'. Meta descriptions have ~155 characters to
spend, so a record's date gives up its day."""
try:
return datetime.date.fromisoformat(str(date_s)).strftime("%b %Y")
except (ValueError, TypeError):
return str(date_s)
def _clamp_desc(text: str, limit: int = 155) -> str:
"""Meta descriptions are cut at ~155 characters in the SERP. Trim on a word
boundary so we choose where the sentence ends rather than Google doing it."""
text = " ".join(text.split())
if len(text) <= limit:
return text
return text[:limit].rsplit(" ", 1)[0].rstrip(",;—-") + ""
def _titled(text: str, suffix: str = " · Thermograph", limit: int = 60) -> str:
"""Brand the title only when it costs nothing — the payload ("… in July",
"hottest & coldest days") has to survive truncation first."""
return text + suffix if len(text) + len(suffix) <= limit else text
def _fmt(metric: str, v) -> str:
if v is None:
return ""
@ -378,6 +411,7 @@ def _today_vs_normal(history, cell) -> dict | None:
def _city_context(request, city, cell, history) -> dict:
name = city["name"]
display = cities.display_name(city)
title = cities.title_name(city)
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
months = _monthly_normals(history)
@ -425,10 +459,13 @@ def _city_context(request, city, cell, history) -> dict:
"tool_hash": tool_hash, "flavor": flavor, "event": event, "compare_url": compare_url,
"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."),
"page_title": _titled(f"{title} climate: daily normals, records & how unusual it is now"),
"page_description": _clamp_desc(
f"{title} averages highs of {_temp_text(warmest['high_f']) if warmest else ''} in "
f"{warmest['name'] if warmest else 'summer'} and lows of "
f"{_temp_text(coldest['low_f']) if coldest else ''} in "
f"{coldest['name'] if coldest else 'winter'}. "
f"Every day graded against {year_range[1] - year_range[0]} years of local history."),
"jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")),
}
@ -453,6 +490,7 @@ def city_page(request: Request, slug: str) -> Response:
# --- month & records pages ----------------------------------------------------
def _month_context(request, city, history, month_idx: int) -> dict:
display = cities.display_name(city)
title = cities.title_name(city)
name = city["name"]
month_name = MONTHS_TITLE[month_idx - 1]
month_slug = MONTHS[month_idx - 1]
@ -526,9 +564,11 @@ def _month_context(request, city, history, month_idx: int) -> dict:
"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."),
"page_title": _titled(f"{title} in {month_name}: normal weather & records"),
"page_description": _clamp_desc(
f"{title} averages {_temp_text(tmax['mean']) if tmax else ''} highs and "
f"{_temp_text(tmin['mean']) if tmin else ''} lows in {month_name}. "
f"Every day graded against {year_range[1] - year_range[0]} years of local history."),
}
@ -541,11 +581,18 @@ def month_page(request: Request, slug: str, month: str) -> Response:
def _records_context(request, city, history) -> dict:
display = cities.display_name(city)
title = cities.title_name(city)
name = city["name"]
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
n_years = year_range[1] - year_range[0]
rec = grading.all_time_records(history)
# The two numbers the meta description leads with — the same all-time extremes
# the page's own lede sentence quotes.
hi_rec = (rec.get("tmax") or {}).get("max")
hi_date = (rec.get("tmax") or {}).get("max_date")
lo_rec = (rec.get("tmin") or {}).get("min")
lo_date = (rec.get("tmin") or {}).get("min_date")
rows = []
for key, label in METRIC_LABELS:
r = rec.get(key)
@ -600,10 +647,13 @@ def _records_context(request, city, history) -> dict:
"hemisphere": hemisphere, "all_time": rec,
"canonical_path": f"/climate/{city['slug']}/records",
"breadcrumb": breadcrumb,
"page_title": f"{display} weather records: monthly & seasonal record highs and lows",
"page_description": (f"Record high and low temperatures for {display} by month and by season, with the "
f"dates they occurred, plus all-time extremes — from ~{n_years} years of daily "
f"climate history."),
"page_title": _titled(
f"{title} weather records: hottest & coldest days since {year_range[0]}"),
"page_description": _clamp_desc(
f"{title}'s hottest day hit {_temp_text(hi_rec)}"
f"{f' ({_month_year(hi_date)})' if hi_date else ''}; its coldest fell to "
f"{_temp_text(lo_rec)}{f' ({_month_year(lo_date)})' if lo_date else ''}. "
f"Every day graded against {n_years} years of local history."),
"jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")),
}

View file

@ -195,6 +195,64 @@ def test_hub_glossary_about(client):
assert client.get(f"{B}/about").status_code == 200
def test_title_name_is_short_and_unique():
# Titles lead with the bare city name so the payload ("… in July") survives
# SERP truncation — display_name()'s region+country is what pushed it off the
# end. The exception is a name that recurs: those must still be told apart,
# and every rendered title must stay unique across the whole city set.
all_cities = cities.all_cities()
titles = [cities.title_name(c) for c in all_cities]
assert len(titles) == len(set(titles)), "two cities share a title"
# The overwhelming majority pay no disambiguation cost at all.
assert sum(1 for t in titles if "," not in t) > 0.9 * len(titles)
by_slug = {c["slug"]: cities.title_name(c) for c in all_cities}
assert by_slug["seattle-washington-us"] == "Seattle"
# Same name, different countries -> country code is enough.
assert by_slug["london-england-gb"] == "London, GB"
assert by_slug["london-ontario-ca"] == "London, CA"
# Same name AND same country -> the country code would collide, so fall back
# to admin1. Without this, both Columbuses would render "Columbus, US".
assert by_slug["columbus-ohio-us"] == "Columbus, Ohio"
assert by_slug["columbus-georgia-us"] == "Columbus, Georgia"
def test_page_titles_lead_with_city_and_payload(client):
for path, must_start, payload in (
(f"{B}/climate/{SLUG}", "London, GB climate", "how unusual it is now"),
(f"{B}/climate/{SLUG}/july", "London, GB in July", "normal weather"),
(f"{B}/climate/{SLUG}/records", "London, GB weather records", "hottest & coldest"),
):
b = client.get(path).text
title = re.search(r"<title>(.*?)</title>", b, re.S).group(1)
title = title.replace("&amp;", "&")
assert title.startswith(must_start), title
assert payload in title, title
# The region+country the old template spent its width on is gone.
assert "England, United Kingdom" not in title
# Whatever else happens, the city and the page's subject are inside the
# first ~40 chars, so a SERP cut can no longer remove the payload.
assert len(must_start) <= 40
# og:title mirrors <title> via base.html.j2's self.title().
og = re.search(r'<meta property="og:title" content="(.*?)"', b, re.S).group(1)
assert og == re.search(r"<title>(.*?)</title>", b, re.S).group(1)
def test_meta_descriptions_lead_with_real_numbers(client):
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july",
f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
desc = re.search(r'<meta name="description" content="(.*?)"', b, re.S).group(1)
assert len(desc) <= 155, f"{len(desc)}: {desc}"
# A real temperature, not a generic "averages, records and rainfall".
assert re.search(r"-?\d+°F", desc), desc
assert desc.endswith("years of local history."), desc
# The records description names the dates the extremes were set.
rec = client.get(f"{B}/climate/{SLUG}/records").text
desc = re.search(r'<meta name="description" content="(.*?)"', rec, re.S).group(1)
assert re.search(r"\([A-Z][a-z]{2} \d{4}\)", desc), desc
def test_climate_pages_carry_convertible_temps(client):
# Every temperature is a client-convertible span (default °F text so crawlers
# and no-JS visitors still see a real value); the old inline "(NN°C)" dual form