SEO: per-city Wikipedia blurbs + travel/compare CTA on city pages (#98)

Add unique editorial content so the programmatic pages don't read as templated:
- gen_flavor.py seeds backend/cities_flavor.json with a short descriptive blurb per
  city from Wikipedia's free REST summary API (no key), validating each match by
  comparing article coordinates to the city's lat/lon so the wrong 'Springfield'
  never attaches. Retries + modest concurrency; ~700/750 cities get a blurb, the
  rest render without one. Text is CC BY-SA, attributed with a 'via Wikipedia' link.
- City pages show the blurb under the intro and a travel callout that deep-links to
  the compare page with the city pre-loaded (/compare#loc=lat,lon) — the visitor
  just adds their own city. Month pages get the same seasonal 'visiting in {month}?'
  compare link. Both add unique per-page text and internal links.
- cities.py gains flavor(slug); tests cover the blurb + attribution + compare CTA.
This commit is contained in:
Emi Griffith 2026-07-15 17:39:47 -07:00 committed by GitHub
parent 3bbd819d1d
commit c3a11ee994
7 changed files with 3663 additions and 1 deletions

View file

@ -4,8 +4,10 @@ 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
def _load() -> list[dict]:
@ -31,6 +33,19 @@ def get(slug: str) -> dict | None:
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 display_name(city: dict) -> str:
"""Human label: 'Seattle, Washington, United States' (drops repeated admin1)."""
parts = [city["name"]]

3502
cities_flavor.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -208,6 +208,10 @@ def _city_context(request, city, cell, history) -> dict:
wettest = max((m for m in months if m["precip_v"] is not None), key=lambda m: m["precip_v"], default=None)
records = grading.all_time_records(history)
tool_hash = f"{city['lat']:.5f},{city['lon']:.5f}"
# Unique editorial blurb (Wikipedia, CC BY-SA) so the page isn't just templated
# stats, and a travel/comfort CTA that pre-fills this city on the compare page.
flavor = cities.flavor(city["slug"])
compare_url = f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}"
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate")]
if city.get("country"):
@ -243,7 +247,7 @@ def _city_context(request, city, cell, history) -> dict:
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest,
"records": records, "today": _today_vs_normal(history, cell),
"tool_hash": tool_hash,
"tool_hash": tool_hash, "flavor": flavor, "compare_url": compare_url,
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}",
"page_title": f"{display} climate: average temperatures, records & how today compares",
@ -312,6 +316,7 @@ def _month_context(request, city, history, month_idx: int) -> dict:
"avg_low": _temp(tmin["mean"]) if tmin else "",
"stats": stats, "records": records,
"tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}",
"compare_url": f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}",
"prev": {"name": MONTHS_TITLE[prev_i - 1], "slug": MONTHS[prev_i - 1]},
"next": {"name": MONTHS_TITLE[next_i - 1], "slug": MONTHS[next_i - 1]},
"breadcrumb": breadcrumb,

110
gen_flavor.py Normal file
View file

@ -0,0 +1,110 @@
"""Offline seeder for backend/cities_flavor.json — a short descriptive blurb per
city, so the crawlable /climate pages carry unique editorial text (not just the
templated stats) and read as distinct pages. Source: Wikipedia's free REST summary
API (no key). Text is CC BY-SA; the city page attributes it with a link.
python gen_flavor.py [--limit N] [--workers 8]
Each city is matched by name (then "name, country" / "name, admin1"), and the match
is validated by comparing Wikipedia's article coordinates to the city's known
lat/lon so we don't attach the wrong "Springfield". Re-run to refresh; existing
entries for cities no longer in cities.json are dropped.
"""
import json
import math
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import quote
import httpx
import cities
OUT_PATH = os.path.join(os.path.dirname(__file__), "cities_flavor.json")
UA = "ThermographBot/1.0 (https://thermograph.org; climate comparison tool)"
SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
def _km(lat1, lon1, lat2, lon2) -> float:
dlat = (lat2 - lat1) * 111.0
dlon = (lon2 - lon1) * 111.0 * math.cos(math.radians(lat1))
return math.hypot(dlat, dlon)
def _trim(text: str, maxlen: int = 300) -> str:
text = " ".join(text.split())
if len(text) <= maxlen:
return text
cut = text[:maxlen]
i = cut.rfind(". ")
return (cut[:i + 1] if i > 80 else cut.rstrip() + "")
def _summary(client: httpx.Client, title: str) -> dict | None:
# Retry through transient throttling (429/5xx/network) so a bulk run doesn't
# silently drop cities to rate limits.
url = SUMMARY + quote(title, safe="")
for attempt in range(4):
try:
r = client.get(url, timeout=20, follow_redirects=True)
if r.status_code == 200:
return r.json()
if r.status_code == 404:
return None # no such article — don't retry
# 429 / 5xx: back off and retry
except Exception: # noqa: BLE001
pass
time.sleep(0.6 * (attempt + 1))
return None
def blurb_for(client: httpx.Client, city: dict) -> dict | None:
candidates = [city["name"]]
if city.get("country"):
candidates.append(f"{city['name']}, {city['country']}")
if city.get("admin1") and city["admin1"] != city["name"]:
candidates.append(f"{city['name']}, {city['admin1']}")
for title in candidates:
d = _summary(client, title)
if not d or d.get("type") == "disambiguation" or not d.get("extract"):
continue
coords = d.get("coordinates")
if coords and _km(city["lat"], city["lon"], coords["lat"], coords["lon"]) <= 75:
url = (d.get("content_urls", {}).get("desktop", {}) or {}).get("page")
return {"extract": _trim(d["extract"]), "url": url, "title": d.get("title")}
return None
def build(limit: int | None = None, workers: int = 8) -> dict:
todo = cities.all_cities()
if limit:
todo = todo[:limit]
out: dict[str, dict] = {}
with httpx.Client(headers={"user-agent": UA}) as client:
def work(c):
b = blurb_for(client, c)
return (c["slug"], b)
with ThreadPoolExecutor(max_workers=workers) as ex:
for i, (slug, b) in enumerate(ex.map(work, todo), 1):
if b:
out[slug] = b
if i % 100 == 0:
print(f" {i}/{len(todo)} processed, {len(out)} with blurbs")
return out
def main() -> None:
args = sys.argv[1:]
limit = int(args[args.index("--limit") + 1]) if "--limit" in args else None
workers = int(args[args.index("--workers") + 1]) if "--workers" in args else 8
flavor = build(limit=limit, workers=workers)
with open(OUT_PATH, "w", encoding="utf-8") as f:
json.dump(flavor, f, ensure_ascii=False, sort_keys=True, indent=0, separators=(",", ":"))
f.write("\n")
print(f"wrote {len(flavor)} blurbs -> {OUT_PATH}")
if __name__ == "__main__":
main()

View file

@ -16,6 +16,11 @@
<b>{{ warmest.name }}</b> ({{ warmest.high }} average high){% endif %}{% if coldest %} and the coldest
is <b>{{ coldest.name }}</b> ({{ coldest.low }} average low){% endif %}.</p>
{% if flavor %}
<p class="city-blurb">{{ flavor.extract }}{% if flavor.url %}
<a class="blurb-src" href="{{ flavor.url }}" rel="nofollow">— via Wikipedia</a>{% endif %}</p>
{% endif %}
{% if today %}
<section class="climate-today">
<h2>How today compares</h2>
@ -55,6 +60,13 @@
</div>
</section>
<section class="city-travel">
<h2>Thinking of visiting {{ name }}?</h2>
<p>Trips live and die by the weather. See how {{ name }}'s comfort stacks up against where
you live — {{ name }} is pre-loaded, just add your city.</p>
<p><a class="cta" href="{{ compare_url }}">Compare {{ name }}'s comfort with your city →</a></p>
</section>
{% if records.tmax or records.tmin %}
<section class="climate-records">
<h2>Record extremes</h2>

View file

@ -30,6 +30,9 @@
<p><a class="cta" href="{{ base }}/#{{ tool_hash }}">See {{ name }}'s live weather grade →</a></p>
<p class="travel-note">Visiting {{ name }} in {{ month_name }}?
<a href="{{ compare_url }}">Compare its comfort with your home city →</a></p>
<p class="climate-foot muted">
<a href="{{ base }}/climate/{{ city.slug }}/{{ prev.slug }}"> {{ prev.name }}</a> ·
<a href="{{ base }}/climate/{{ city.slug }}">{{ name }} climate overview</a> ·

View file

@ -60,6 +60,21 @@ def test_city_404(client):
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
def test_city_travel_cta_prefills_compare(client):
b = client.get(f"{B}/climate/{SLUG}").text
assert "Thinking of visiting" in b
assert "/compare#loc=" in b # the city is pre-loaded on the compare page
def test_city_blurb_renders_with_attribution(client, monkeypatch):
monkeypatch.setattr(cities, "flavor",
lambda slug: {"extract": "Testville is a lovely place to test.",
"url": "https://en.wikipedia.org/wiki/Testville", "title": "Testville"})
b = client.get(f"{B}/climate/{SLUG}").text
assert "Testville is a lovely place to test." in b
assert "via Wikipedia" in b # CC BY-SA attribution link
def test_month_and_records(client):
assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200
assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200