feat: add Python core and infra functions — PWA, geocoding, POI matching
Nuevas funciones Python: build_guide_prompt, generate_pwa_manifest, generate_service_worker, match_pois_to_interests (core), nominatim_reverse_geocode, ollama_chat, overpass_nearby_pois (infra). Incluye tests unitarios. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""Consulta la Overpass API para obtener POIs cercanos a una coordenada."""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
# Mapeo de categoría legible → (key OSM, value OSM)
|
||||
# None como value significa wildcard (key=*)
|
||||
_CATEGORY_TAGS: dict[str, tuple[str, str | None]] = {
|
||||
"restaurant": ("amenity", "restaurant"),
|
||||
"cafe": ("amenity", "cafe"),
|
||||
"bar": ("amenity", "bar"),
|
||||
"museum": ("tourism", "museum"),
|
||||
"monument": ("historic", "monument"),
|
||||
"park": ("leisure", "park"),
|
||||
"library": ("amenity", "library"),
|
||||
"theatre": ("amenity", "theatre"),
|
||||
"cinema": ("amenity", "cinema"),
|
||||
"gallery": ("tourism", "gallery"),
|
||||
"historic": ("historic", None),
|
||||
"tourism": ("tourism", None),
|
||||
"shop": ("shop", None),
|
||||
"hotel": ("tourism", "hotel"),
|
||||
"pharmacy": ("amenity", "pharmacy"),
|
||||
"hospital": ("amenity", "hospital"),
|
||||
}
|
||||
|
||||
_DEFAULT_CATEGORIES = [
|
||||
"restaurant", "cafe", "bar", "museum", "monument", "park",
|
||||
"library", "theatre", "cinema", "gallery", "historic", "tourism", "shop",
|
||||
]
|
||||
|
||||
_OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
|
||||
def _build_query(lat: float, lon: float, radius_m: int, categories: list[str]) -> str:
|
||||
"""Construye una query Overpass QL con union de nodos."""
|
||||
lines = ["[out:json][timeout:10];", "("]
|
||||
for cat in categories:
|
||||
if cat not in _CATEGORY_TAGS:
|
||||
continue
|
||||
key, value = _CATEGORY_TAGS[cat]
|
||||
if value is None:
|
||||
lines.append(f' node["{key}"](around:{radius_m},{lat},{lon});')
|
||||
else:
|
||||
lines.append(f' node["{key}"="{value}"](around:{radius_m},{lat},{lon});')
|
||||
lines.append(");")
|
||||
lines.append("out body;")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _extract_category(tags: dict) -> str:
|
||||
"""Determina la categoría legible a partir de los tags OSM del nodo."""
|
||||
for cat, (key, value) in _CATEGORY_TAGS.items():
|
||||
if key not in tags:
|
||||
continue
|
||||
if value is None or tags[key] == value:
|
||||
return cat
|
||||
return "unknown"
|
||||
|
||||
|
||||
def overpass_nearby_pois(
|
||||
lat: float,
|
||||
lon: float,
|
||||
radius_m: int = 500,
|
||||
categories: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Obtiene POIs cercanos a una coordenada usando la Overpass API.
|
||||
|
||||
Args:
|
||||
lat: latitud del punto central.
|
||||
lon: longitud del punto central.
|
||||
radius_m: radio de busqueda en metros (defecto 500).
|
||||
categories: lista de categorias a buscar. Si es None, usa todas las
|
||||
categorias comunes definidas internamente.
|
||||
|
||||
Returns:
|
||||
lista de dicts con id, lat, lon, name, category y tags de cada POI.
|
||||
|
||||
Raises:
|
||||
RuntimeError: si la request falla por error HTTP, timeout u otro error de red.
|
||||
"""
|
||||
active_categories = categories if categories is not None else _DEFAULT_CATEGORIES
|
||||
query = _build_query(lat, lon, radius_m, active_categories)
|
||||
|
||||
encoded_query = urllib.parse.quote(query)
|
||||
form_body = f"data={encoded_query}".encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
_OVERPASS_ENDPOINT,
|
||||
data=form_body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
raw = response.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
body_preview = e.read(200).decode("utf-8", errors="replace")
|
||||
raise RuntimeError(
|
||||
f"Overpass API HTTP error {e.code} for ({lat},{lon}) r={radius_m}m: {body_preview}"
|
||||
) from e
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(
|
||||
f"Overpass API request failed for ({lat},{lon}) r={radius_m}m: {e.reason}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(
|
||||
f"Overpass API returned invalid JSON for ({lat},{lon}): {e}"
|
||||
) from e
|
||||
|
||||
pois: list[dict] = []
|
||||
for element in result.get("elements", []):
|
||||
if element.get("type") != "node":
|
||||
continue
|
||||
tags = element.get("tags", {})
|
||||
category = _extract_category(tags)
|
||||
# Nombre: tag "name", o valor del tag de categoría, o "Unknown"
|
||||
if "name" in tags:
|
||||
name = tags["name"]
|
||||
else:
|
||||
key = _CATEGORY_TAGS.get(category, (None, None))[0]
|
||||
name = tags.get(key, "Unknown") if key else "Unknown"
|
||||
|
||||
pois.append({
|
||||
"id": element["id"],
|
||||
"lat": element["lat"],
|
||||
"lon": element["lon"],
|
||||
"name": name,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
})
|
||||
|
||||
return pois
|
||||
Reference in New Issue
Block a user