custom_cities.json is merged into the search index for towns missing from the geonames 15k+ dataset; labels now include the region code when available to disambiguate same-named cities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
95 lines
3.3 KiB
Python
95 lines
3.3 KiB
Python
"""City search (offline, geonamescache + custom_cities.json) + historical timezone resolution."""
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import geonamescache
|
|
from timezonefinder import TimezoneFinder
|
|
|
|
_gc = geonamescache.GeonamesCache()
|
|
_tf = TimezoneFinder()
|
|
_countries = {c["iso"]: c["name"] for c in _gc.get_countries().values()}
|
|
|
|
_CITY_INDEX = None
|
|
|
|
|
|
def _custom_cities():
|
|
"""Supplemental cities missing from the geonames 15k+ dataset (custom_cities.json)."""
|
|
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "custom_cities.json")
|
|
if not os.path.exists(path):
|
|
return []
|
|
with open(path) as f:
|
|
rows = []
|
|
for c in json.load(f):
|
|
rows.append({
|
|
"name": c["name"],
|
|
"search": c["name"].lower(),
|
|
"alt": [a.lower() for a in c.get("alt", [])],
|
|
"country": c["country"],
|
|
"cc": c["cc"],
|
|
"admin": c.get("admin", ""),
|
|
"lat": float(c["lat"]),
|
|
"lon": float(c["lon"]),
|
|
"pop": int(c.get("pop", 0)),
|
|
"tz": c["tz"],
|
|
})
|
|
return rows
|
|
|
|
|
|
def _index():
|
|
global _CITY_INDEX
|
|
if _CITY_INDEX is None:
|
|
rows = _custom_cities()
|
|
for c in _gc.get_cities().values():
|
|
rows.append({
|
|
"name": c["name"],
|
|
"search": c["name"].lower(),
|
|
"alt": [a.lower() for a in c.get("alternatenames", [])[:20]],
|
|
"country": _countries.get(c["countrycode"], c["countrycode"]),
|
|
"cc": c["countrycode"],
|
|
"admin": c.get("admin1code", ""),
|
|
"lat": c["latitude"],
|
|
"lon": c["longitude"],
|
|
"pop": c["population"],
|
|
"tz": c["timezone"],
|
|
})
|
|
rows.sort(key=lambda r: -r["pop"])
|
|
_CITY_INDEX = rows
|
|
return _CITY_INDEX
|
|
|
|
|
|
def search_cities(q, limit=12):
|
|
q = q.strip().lower()
|
|
if len(q) < 2:
|
|
return []
|
|
starts, contains = [], []
|
|
for r in _index():
|
|
if r["search"].startswith(q):
|
|
starts.append(r)
|
|
elif q in r["search"] or any(a.startswith(q) for a in r["alt"]):
|
|
contains.append(r)
|
|
if len(starts) >= limit:
|
|
break
|
|
rows = (starts + contains)[:limit]
|
|
return [{"name": r["name"], "country": r["country"], "cc": r["cc"],
|
|
"admin": r["admin"], "lat": r["lat"], "lon": r["lon"], "tz": r["tz"],
|
|
"label": (f'{r["name"]}, {r["admin"]}, {r["country"]}'
|
|
if r["admin"] and not r["admin"].isdigit()
|
|
else f'{r["name"]}, {r["country"]}')} for r in rows]
|
|
|
|
|
|
def resolve_offset(lat, lon, year, month, day, hour, minute, tz_hint=None):
|
|
"""Return (utc_offset_hours, tz_name) for the LOCAL wall-clock birth time.
|
|
|
|
Uses the IANA tz database, which carries historical offsets and DST rules —
|
|
the same thing the 90s programs shipped as their 'atlas'.
|
|
"""
|
|
tzname = tz_hint or _tf.timezone_at(lat=lat, lng=lon)
|
|
if not tzname:
|
|
tzname = _tf.closest_timezone_at(lat=lat, lng=lon) if hasattr(_tf, "closest_timezone_at") else "UTC"
|
|
tz = ZoneInfo(tzname)
|
|
local = datetime(year, month, day, hour, minute, tzinfo=tz)
|
|
off = local.utcoffset().total_seconds() / 3600.0
|
|
return off, tzname
|