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('{}°').format(f, round(f)) +def _temp_text(f) -> str: + """'72°F' as plain text. The span _temp() returns can't go in a + , 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"