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:
parent
3bbd819d1d
commit
c3a11ee994
7 changed files with 3663 additions and 1 deletions
15
cities.py
15
cities.py
|
|
@ -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
3502
cities_flavor.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
110
gen_flavor.py
Normal 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()
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> ·
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue