Files
HexaWetter/api/app/services/geocoding.py

147 lines
4.2 KiB
Python

from __future__ import annotations
import hashlib
import re
from typing import Any
from app.config import CACHE_TTL_GEOCODE, NOMINATIM_BASE_URL, NOMINATIM_USER_AGENT
from app.http_client import get_client
from app.database import get_cached_json, set_cached_json
_POSTAL_ONLY = re.compile(r"^\d{4,5}$")
_POSTAL_CITY = re.compile(r"^(\d{4,5})\s+(.+)$")
def _query_hash(query: str) -> str:
return hashlib.sha256(query.strip().lower().encode("utf-8")).hexdigest()
def _parse_nominatim_item(item: dict[str, Any]) -> dict[str, Any]:
address = item.get("address") or {}
label = item.get("display_name", "")
place = (
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or address.get("county")
or label.split(",")[0]
)
return {
"label": label,
"place": place,
"lat": float(item["lat"]),
"lon": float(item["lon"]),
"type": item.get("type"),
"postcode": address.get("postcode"),
"county": address.get("county"),
"state": address.get("state"),
}
def _country_codes_for_postal(postal: str) -> str:
if len(postal) == 5:
return "de"
return "at,ch"
def _build_search_params(query: str, limit: int) -> dict[str, Any]:
base = {
"format": "jsonv2",
"addressdetails": 1,
"limit": limit,
}
city_match = _POSTAL_CITY.match(query)
if city_match:
postal, city = city_match.groups()
return {
**base,
"postalcode": postal,
"city": city.strip(),
"countrycodes": _country_codes_for_postal(postal),
}
if _POSTAL_ONLY.match(query):
return {
**base,
"postalcode": query,
"countrycodes": _country_codes_for_postal(query),
}
return {
**base,
"q": query,
"countrycodes": "de,at,ch",
}
async def _nominatim_get(path: str, params: dict[str, Any]) -> Any:
headers = {"User-Agent": NOMINATIM_USER_AGENT, "Accept-Language": "de"}
client = get_client()
response = await client.get(f"{NOMINATIM_BASE_URL}{path}", params=params, headers=headers, timeout=12.0)
response.raise_for_status()
return response.json()
async def search_places(query: str, limit: int = 8) -> list[dict[str, Any]]:
query = query.strip()
if len(query) < 2:
return []
if query.isdigit() and len(query) < 4:
return []
qhash = _query_hash(f"search:{query}:{limit}")
cached = await get_cached_json("geocode_cache", {"query_hash": qhash}, CACHE_TTL_GEOCODE)
if cached is not None:
return cached
raw = await _nominatim_get("/search", _build_search_params(query, limit))
results = [_parse_nominatim_item(item) for item in raw]
await set_cached_json(
"geocode_cache",
{"query_hash": qhash, "query_text": query},
results,
)
return results
async def reverse_geocode(lat: float, lon: float) -> dict[str, Any]:
qhash = _query_hash(f"reverse:{lat:.4f}:{lon:.4f}")
cached = await get_cached_json("geocode_cache", {"query_hash": qhash}, CACHE_TTL_GEOCODE)
if cached is not None:
return cached
raw = await _nominatim_get(
"/reverse",
{"lat": lat, "lon": lon, "format": "jsonv2", "addressdetails": 1, "zoom": 10},
)
address = raw.get("address") or {}
payload = {
"label": raw.get("display_name"),
"place": address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or address.get("county"),
"county": address.get("county"),
"state": address.get("state"),
"postcode": address.get("postcode"),
"admin_names": [
value
for value in [
address.get("city"),
address.get("town"),
address.get("village"),
address.get("municipality"),
address.get("county"),
address.get("state"),
]
if value
],
}
await set_cached_json(
"geocode_cache",
{"query_hash": qhash, "query_text": f"{lat},{lon}"},
payload,
)
return payload