thermograph/gen_flavor.py
Emi Griffith d17ac794fd Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module

Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.

Groundwork for moving modules into packages without re-pointing paths.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62

* Split the backend into domain packages

Group the flat backend modules into packages that mirror their concerns:
  data/          climate, grading, scoring, grid, places, cities,
                 city_events, store
  web/           app, views, homepage, content, schemas
  notifications/ notify, digest, push, mailer, discord,
                 discord_interactions, discord_link
  accounts/      models, users, api_accounts, db
  core/          metrics, singleton, audit

Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.

Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00

127 lines
4.8 KiB
Python

"""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
from data import cities
import paths
OUT_PATH = paths.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(todo: list[dict], workers: int = 5) -> dict:
"""Fetch a blurb for each city in `todo`; returns {slug: {...}} for the hits."""
out: dict[str, dict] = {}
with httpx.Client(headers={"user-agent": UA}) as client:
def work(c):
return (c["slug"], blurb_for(client, c))
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)} new 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 5
full = "--full" in args # re-fetch everything, else only cities missing a blurb
# Incremental by default: keep existing blurbs, fetch only the missing ones, and
# prune entries for cities no longer in cities.json.
existing: dict[str, dict] = {}
if not full:
try:
with open(OUT_PATH, encoding="utf-8") as f:
existing = json.load(f)
except (OSError, ValueError):
existing = {}
all_cities = cities.all_cities()
all_slugs = {c["slug"] for c in all_cities}
todo = [c for c in all_cities if full or c["slug"] not in existing]
if limit:
todo = todo[:limit]
print(f"fetching {len(todo)} cities ({'full rebuild' if full else 'missing only'})")
fetched = build(todo, workers=workers)
merged = {s: v for s, v in {**existing, **fetched}.items() if s in all_slugs}
with open(OUT_PATH, "w", encoding="utf-8") as f:
json.dump(merged, f, ensure_ascii=False, sort_keys=True, indent=0, separators=(",", ":"))
f.write("\n")
print(f"wrote {len(merged)} blurbs ({len(fetched)} newly fetched) -> {OUT_PATH}")
if __name__ == "__main__":
main()