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:
parent
7d17d8bc4f
commit
1720ac1da6
3 changed files with 153 additions and 11 deletions
34
cities.py
34
cities.py
|
|
@ -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"]]
|
||||
|
|
|
|||
72
content.py
72
content.py
|
|
@ -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=(",", ":")),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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("&", "&")
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue