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
|
import os
|
||||||
|
|
||||||
_PATH = os.path.join(os.path.dirname(__file__), "cities.json")
|
_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
|
_CITIES: list[dict] | None = None
|
||||||
_BY_SLUG: dict[str, dict] | None = None
|
_BY_SLUG: dict[str, dict] | None = None
|
||||||
|
_FLAVOR: dict[str, dict] | None = None
|
||||||
|
|
||||||
|
|
||||||
def _load() -> list[dict]:
|
def _load() -> list[dict]:
|
||||||
|
|
@ -31,6 +33,19 @@ def get(slug: str) -> dict | None:
|
||||||
return _BY_SLUG.get(slug)
|
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:
|
def display_name(city: dict) -> str:
|
||||||
"""Human label: 'Seattle, Washington, United States' (drops repeated admin1)."""
|
"""Human label: 'Seattle, Washington, United States' (drops repeated admin1)."""
|
||||||
parts = [city["name"]]
|
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)
|
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)
|
records = grading.all_time_records(history)
|
||||||
tool_hash = f"{city['lat']:.5f},{city['lon']:.5f}"
|
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")]
|
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate")]
|
||||||
if city.get("country"):
|
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],
|
"year_range": year_range, "n_years": year_range[1] - year_range[0],
|
||||||
"months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest,
|
"months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest,
|
||||||
"records": records, "today": _today_vs_normal(history, cell),
|
"records": records, "today": _today_vs_normal(history, cell),
|
||||||
"tool_hash": tool_hash,
|
"tool_hash": tool_hash, "flavor": flavor, "compare_url": compare_url,
|
||||||
"breadcrumb": breadcrumb,
|
"breadcrumb": breadcrumb,
|
||||||
"canonical_path": f"/climate/{city['slug']}",
|
"canonical_path": f"/climate/{city['slug']}",
|
||||||
"page_title": f"{display} climate: average temperatures, records & how today compares",
|
"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 "—",
|
"avg_low": _temp(tmin["mean"]) if tmin else "—",
|
||||||
"stats": stats, "records": records,
|
"stats": stats, "records": records,
|
||||||
"tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}",
|
"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]},
|
"prev": {"name": MONTHS_TITLE[prev_i - 1], "slug": MONTHS[prev_i - 1]},
|
||||||
"next": {"name": MONTHS_TITLE[next_i - 1], "slug": MONTHS[next_i - 1]},
|
"next": {"name": MONTHS_TITLE[next_i - 1], "slug": MONTHS[next_i - 1]},
|
||||||
"breadcrumb": breadcrumb,
|
"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
|
<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>
|
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 %}
|
{% if today %}
|
||||||
<section class="climate-today">
|
<section class="climate-today">
|
||||||
<h2>How today compares</h2>
|
<h2>How today compares</h2>
|
||||||
|
|
@ -55,6 +60,13 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 %}
|
{% if records.tmax or records.tmin %}
|
||||||
<section class="climate-records">
|
<section class="climate-records">
|
||||||
<h2>Record extremes</h2>
|
<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><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">
|
<p class="climate-foot muted">
|
||||||
<a href="{{ base }}/climate/{{ city.slug }}/{{ prev.slug }}">‹ {{ prev.name }}</a> ·
|
<a href="{{ base }}/climate/{{ city.slug }}/{{ prev.slug }}">‹ {{ prev.name }}</a> ·
|
||||||
<a href="{{ base }}/climate/{{ city.slug }}">{{ name }} climate overview</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
|
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):
|
def test_month_and_records(client):
|
||||||
assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200
|
assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200
|
||||||
assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200
|
assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue