"""Proxy geokodera (OpenStreetMap / Nominatim) dla wyszukiwarki lokalizacji w UI. QoL: pozwala wpisać nazwę / adres / POI i dostać współrzędne (oraz odwrotnie — z punktu na mapie nazwę miejsca). Wołamy Nominatim **po stronie serwera**, nie z przeglądarki, bo: - polityka Nominatim wymaga sensownego User-Agent i max ~1 zapytanie/s — łatwiej to wyegzekwować i cache'ować centralnie niż z wielu klientów, - działa niezależnie od „secure context" przeglądarki (na http:// też). Atrybucja „© OpenStreetMap contributors" jest pokazywana w UI (mapa + wyniki). Zależność sieciowa jest OPCJONALNA: brak internetu = wyszukiwarka zwraca błąd, ale ręczne wpisanie lat/lon oraz mapa działają dalej. """ from __future__ import annotations import threading import time from typing import Any import httpx NOMINATIM = "https://nominatim.openstreetmap.org" # Nominatim wymaga identyfikującego User-Agent (inaczej blokuje). Kontakt = właściciel. USER_AGENT = "astrololo/1.0 (self-hosted astrology tool; +mtuszowski@gmail.com)" _MIN_INTERVAL = 1.1 # s — polityka Nominatim: max ~1 zapytanie/s _CACHE_TTL = 3600.0 # s — powtórki tego samego zapytania z pamięci przez godzinę _TIMEOUT = 6.0 _lock = threading.Lock() # serializuje wołania (throttling globalny w procesie) _last_call = 0.0 _cache: dict[str, tuple[float, Any]] = {} def _throttle() -> None: global _last_call wait = _MIN_INTERVAL - (time.monotonic() - _last_call) if wait > 0: time.sleep(wait) _last_call = time.monotonic() def _cached(key: str) -> Any | None: hit = _cache.get(key) if hit and (time.monotonic() - hit[0]) < _CACHE_TTL: return hit[1] return None def _get(path: str, params: dict) -> Any: """Wołanie Nominatim z throttlingiem; zakłada trzymany `_lock`.""" _throttle() r = httpx.get( f"{NOMINATIM}{path}", params=params, headers={"User-Agent": USER_AGENT, "Accept-Language": "pl"}, timeout=_TIMEOUT, ) r.raise_for_status() return r.json() def search(query: str, limit: int = 5) -> list[dict]: """Nazwa/adres/POI → lista kandydatów [{name, lat, lon}] (najlepsze pierwsze).""" query = (query or "").strip() if not query: return [] key = f"s:{limit}:{query.lower()}" with _lock: cached = _cached(key) if cached is not None: return cached data = _get("/search", {"q": query, "format": "jsonv2", "limit": limit, "addressdetails": 0}) out = [ {"name": item.get("display_name", ""), "lat": round(float(item["lat"]), 6), "lon": round(float(item["lon"]), 6)} for item in data if "lat" in item and "lon" in item ] _cache[key] = (time.monotonic(), out) return out def reverse(lat: float, lon: float) -> dict: """Punkt (lat, lon) → nazwa miejsca {name, lat, lon}.""" key = f"r:{round(lat, 5)}:{round(lon, 5)}" with _lock: cached = _cached(key) if cached is not None: return cached data = _get("/reverse", {"lat": lat, "lon": lon, "format": "jsonv2"}) out = {"name": data.get("display_name", ""), "lat": round(lat, 6), "lon": round(lon, 6)} _cache[key] = (time.monotonic(), out) return out