From 1720ac1da652f184882a868fcf7ebd9fa88383a3 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 19 Jul 2026 11:17:13 -0700 Subject: [PATCH] Lead climate page titles with the city and the payload (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8 --- cities.py | 34 ++++++++++++++++++++ content.py | 72 ++++++++++++++++++++++++++++++++++++------- tests/test_content.py | 58 ++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 11 deletions(-) diff --git a/cities.py b/cities.py index 2f48bb2..88d7ea5 100644 --- a/cities.py +++ b/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"]] diff --git a/content.py b/content.py index 9fba2b9..2c049ca 100644 --- a/content.py +++ b/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=(",", ":")), } diff --git a/tests/test_content.py b/tests/test_content.py index bf9dbb1..e24d563 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -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>(.*?)", 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 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>(.*?)", 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'