scripts/maps_client.py
scripts/maps_client.pyBrowse 2 files
10,913 tokens
46,678 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""3maps_client.py - CLI tool for maps, geocoding, routing, POI search, and more.4Uses only Python stdlib. Data from OpenStreetMap/Nominatim, Overpass API, OSRM,5and TimeAPI.io.6 7Commands:8 search - Geocode a place name to coordinates9 reverse - Reverse geocode coordinates to an address10 nearby - Find nearby POIs by category11 distance - Road distance and travel time between two places12 directions - Turn-by-turn directions between two places13 timezone - Timezone info for coordinates14 bbox - Find POIs within a bounding box15 area - Get bounding box and area info for a named place16"""17 18import argparse19import json20import math21import sys22import time23import urllib.error24import urllib.parse25import urllib.request26 27# ---------------------------------------------------------------------------28# Constants29# ---------------------------------------------------------------------------30 31USER_AGENT = "HermesAgent/1.0 (contact: hermes@agent.ai)"32DATA_SOURCE = "OpenStreetMap/Nominatim"33 34NOMINATIM_SEARCH = "https://nominatim.openstreetmap.org/search"35NOMINATIM_REVERSE = "https://nominatim.openstreetmap.org/reverse"36# Public Overpass endpoints. We try them in order so a single server37# outage doesn't break the skill — kumi.systems is a well-known mirror.38OVERPASS_URLS = [39 "https://overpass-api.de/api/interpreter",40 "https://overpass.kumi.systems/api/interpreter",41]42# Backward-compat alias for any caller that imports OVERPASS_API directly.43OVERPASS_API = OVERPASS_URLS[0]44OSRM_BASE = "https://router.project-osrm.org/route/v1"45TIMEAPI_BASE = "https://timeapi.io/api/timezone/coordinate"46 47# Seconds to sleep between Nominatim requests (ToS requirement)48NOMINATIM_RATE_LIMIT = 1.049 50# Maximum retries for HTTP errors51MAX_RETRIES = 352RETRY_DELAY = 2.0 # seconds53 54# Category -> (OSM tag key, OSM tag value)55CATEGORY_TAGS = {56 # Food & Drink57 "restaurant": ("amenity", "restaurant"),58 "cafe": ("amenity", "cafe"),59 "bar": ("amenity", "bar"),60 # bakery is tagged as shop=bakery in the OSM wiki, but some mappers use61 # amenity=bakery. Search both so small indie bakeries aren't missed.62 "bakery": [("shop", "bakery"), ("amenity", "bakery")],63 "convenience_store": ("shop", "convenience"),64 # Health65 "hospital": ("amenity", "hospital"),66 "pharmacy": ("amenity", "pharmacy"),67 "dentist": ("amenity", "dentist"),68 "doctor": ("amenity", "doctors"),69 "veterinary": ("amenity", "veterinary"),70 # Accommodation71 "hotel": ("tourism", "hotel"),72 "guest_house": ("tourism", "guest_house"),73 "camp_site": ("tourism", "camp_site"),74 # Shopping & Services75 "supermarket": ("shop", "supermarket"),76 "bookshop": ("shop", "books"),77 "laundry": ("shop", "laundry"),78 # Finance79 "atm": ("amenity", "atm"),80 "bank": ("amenity", "bank"),81 # Transport82 "gas_station": ("amenity", "fuel"),83 "parking": ("amenity", "parking"),84 "airport": ("aeroway", "aerodrome"),85 "train_station": ("railway", "station"),86 "bus_stop": ("highway", "bus_stop"),87 "taxi": ("amenity", "taxi"),88 "car_wash": ("amenity", "car_wash"),89 "car_rental": ("amenity", "car_rental"),90 "bicycle_rental": ("amenity", "bicycle_rental"),91 # Culture & Entertainment92 "museum": ("tourism", "museum"),93 "cinema": ("amenity", "cinema"),94 "theatre": ("amenity", "theatre"),95 "nightclub": ("amenity", "nightclub"),96 "zoo": ("tourism", "zoo"),97 # Education98 "school": ("amenity", "school"),99 "university": ("amenity", "university"),100 "library": ("amenity", "library"),101 # Public Services102 "police": ("amenity", "police"),103 "fire_station": ("amenity", "fire_station"),104 "post_office": ("amenity", "post_office"),105 # Religion106 "church": ("amenity", "place_of_worship"), # refined by religion tag107 "mosque": ("amenity", "place_of_worship"),108 "synagogue": ("amenity", "place_of_worship"),109 # Recreation110 "park": ("leisure", "park"),111 "gym": ("leisure", "fitness_centre"),112 "swimming_pool": ("leisure", "swimming_pool"),113 "playground": ("leisure", "playground"),114 "stadium": ("leisure", "stadium"),115}116 117# Religion-specific overrides for place_of_worship categories118RELIGION_FILTER = {119 "church": "christian",120 "mosque": "muslim",121 "synagogue": "jewish",122}123 124VALID_CATEGORIES = sorted(CATEGORY_TAGS.keys())125 126 127def _tags_for(category):128 """Return the CATEGORY_TAGS entry as a list of (key, value) pairs.129 130 Most categories map to a single (tag_key, tag_val) tuple, but some131 (e.g. ``bakery``) are tagged under more than one OSM key and are132 represented as a list of tuples. Normalise both forms to a list.133 """134 entry = CATEGORY_TAGS[category]135 if isinstance(entry, list):136 return list(entry)137 return [entry]138 139OSRM_PROFILES = {140 "driving": "driving",141 "walking": "foot",142 "cycling": "bike",143}144 145# ---------------------------------------------------------------------------146# Output helpers147# ---------------------------------------------------------------------------148 149def print_json(data):150 """Print data as pretty-printed JSON to stdout."""151 print(json.dumps(data, indent=2, ensure_ascii=False))152 153 154def error_exit(message, code=1):155 """Print an error result as JSON and exit."""156 print_json({"error": message, "status": "error"})157 sys.exit(code)158 159 160# ---------------------------------------------------------------------------161# HTTP helpers162# ---------------------------------------------------------------------------163 164def http_get(url, params=None, retries=MAX_RETRIES, silent=False):165 """166 Perform an HTTP GET request, returning parsed JSON.167 Adds the required User-Agent header. Retries on transient errors.168 If silent=True, raises RuntimeError instead of calling error_exit.169 """170 if params:171 url = url + "?" + urllib.parse.urlencode(params)172 173 req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})174 175 last_error = None176 for attempt in range(1, retries + 1):177 try:178 with urllib.request.urlopen(req, timeout=15) as resp:179 raw = resp.read().decode("utf-8")180 return json.loads(raw)181 except urllib.error.HTTPError as exc:182 last_error = f"HTTP {exc.code}: {exc.reason} for {url}"183 if exc.code in {429, 503, 502, 504}:184 time.sleep(RETRY_DELAY * attempt)185 else:186 if silent:187 raise RuntimeError(last_error)188 error_exit(last_error)189 except urllib.error.URLError as exc:190 last_error = f"URL error: {exc.reason}"191 time.sleep(RETRY_DELAY * attempt)192 except json.JSONDecodeError as exc:193 last_error = f"JSON parse error: {exc}"194 time.sleep(RETRY_DELAY * attempt)195 196 msg = f"Request failed after {retries} attempts. Last error: {last_error}"197 if silent:198 raise RuntimeError(msg)199 error_exit(msg)200 201 202def http_get_text(url, params=None, retries=MAX_RETRIES, silent=False):203 """204 Like http_get but returns raw text instead of parsed JSON.205 Useful for APIs that may return non-JSON responses.206 """207 if params:208 url = url + "?" + urllib.parse.urlencode(params)209 210 req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})211 212 last_error = None213 for attempt in range(1, retries + 1):214 try:215 with urllib.request.urlopen(req, timeout=15) as resp:216 return resp.read().decode("utf-8")217 except urllib.error.HTTPError as exc:218 last_error = f"HTTP {exc.code}: {exc.reason} for {url}"219 if exc.code in {429, 503, 502, 504}:220 time.sleep(RETRY_DELAY * attempt)221 else:222 if silent:223 raise RuntimeError(last_error)224 error_exit(last_error)225 except urllib.error.URLError as exc:226 last_error = f"URL error: {exc.reason}"227 time.sleep(RETRY_DELAY * attempt)228 229 msg = f"Request failed after {retries} attempts. Last error: {last_error}"230 if silent:231 raise RuntimeError(msg)232 error_exit(msg)233 234 235def http_post(url, data_str, retries=MAX_RETRIES):236 """237 Perform an HTTP POST with a plain-text body (for Overpass QL).238 Returns parsed JSON.239 """240 encoded = data_str.encode("utf-8")241 req = urllib.request.Request(242 url,243 data=encoded,244 headers={245 "User-Agent": USER_AGENT,246 "Content-Type": "application/x-www-form-urlencoded",247 },248 )249 250 last_error = None251 for attempt in range(1, retries + 1):252 try:253 with urllib.request.urlopen(req, timeout=30) as resp:254 raw = resp.read().decode("utf-8")255 return json.loads(raw)256 except urllib.error.HTTPError as exc:257 last_error = f"HTTP {exc.code}: {exc.reason}"258 if exc.code in {429, 503, 502, 504}:259 time.sleep(RETRY_DELAY * attempt)260 else:261 error_exit(last_error)262 except urllib.error.URLError as exc:263 last_error = f"URL error: {exc.reason}"264 time.sleep(RETRY_DELAY * attempt)265 except json.JSONDecodeError as exc:266 last_error = f"JSON parse error: {exc}"267 time.sleep(RETRY_DELAY * attempt)268 269 error_exit(f"POST failed after {retries} attempts. Last error: {last_error}")270 271 272def overpass_query(query):273 """POST an Overpass QL query, trying each URL in OVERPASS_URLS in turn.274 275 A single public Overpass mirror can be rate-limited or down; trying the276 next mirror before giving up turns a flaky outage into a retry. Returns277 parsed JSON. Falls through to error_exit if every mirror fails.278 """279 post_data = "data=" + urllib.parse.quote(query)280 last_error = None281 for url in OVERPASS_URLS:282 try:283 return http_post(url, post_data, retries=1)284 except SystemExit:285 # error_exit inside http_post — keep trying the next mirror.286 last_error = f"mirror {url} exhausted retries"287 continue288 except Exception as exc:289 last_error = f"{url}: {exc}"290 continue291 error_exit(292 f"All Overpass mirrors failed. Last error: {last_error or 'unknown'}"293 )294 295 296# ---------------------------------------------------------------------------297# Geo math298# ---------------------------------------------------------------------------299 300def haversine_m(lat1, lon1, lat2, lon2):301 """Return distance in metres between two lat/lon points (Haversine)."""302 R = 6_371_000 # Earth mean radius in metres303 phi1 = math.radians(lat1)304 phi2 = math.radians(lat2)305 dphi = math.radians(lat2 - lat1)306 dlam = math.radians(lon2 - lon1)307 a = (math.sin(dphi / 2) ** 2308 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2)309 return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))310 311 312# ---------------------------------------------------------------------------313# Nominatim helpers314# ---------------------------------------------------------------------------315 316def nominatim_search(query, limit=5):317 """Geocode a free-text query. Returns list of result dicts."""318 params = {319 "q": query,320 "format": "json",321 "limit": limit,322 "addressdetails": 1,323 }324 time.sleep(NOMINATIM_RATE_LIMIT)325 return http_get(NOMINATIM_SEARCH, params=params)326 327 328def nominatim_reverse(lat, lon):329 """Reverse geocode lat/lon. Returns a single result dict."""330 params = {331 "lat": lat,332 "lon": lon,333 "format": "json",334 "addressdetails": 1,335 }336 time.sleep(NOMINATIM_RATE_LIMIT)337 return http_get(NOMINATIM_REVERSE, params=params)338 339 340def geocode_single(query):341 """342 Geocode a query and return (lat, lon, display_name).343 Exits with error if nothing found.344 """345 results = nominatim_search(query, limit=1)346 if not results:347 error_exit(f"Could not geocode: {query}")348 r = results[0]349 return float(r["lat"]), float(r["lon"]), r.get("display_name", query)350 351 352# ---------------------------------------------------------------------------353# Overpass helpers354# ---------------------------------------------------------------------------355 356def build_overpass_nearby(tag_key, tag_val, lat, lon, radius, limit,357 religion=None, tag_pairs=None):358 """Build an Overpass QL query for nearby POIs around a point.359 360 If ``tag_pairs`` is provided, the query unions across every361 ``(key, value)`` pair (used for categories like ``bakery`` that are362 tagged under more than one OSM key). Otherwise falls back to the363 single ``tag_key``/``tag_val`` pair for back-compat.364 """365 pairs = tag_pairs if tag_pairs else [(tag_key, tag_val)]366 religion_filter = ""367 if religion:368 religion_filter = f'["religion"="{religion}"]'369 body_lines = []370 for k, v in pairs:371 body_lines.append(372 f' node["{k}"="{v}"]{religion_filter}'373 f'(around:{radius},{lat},{lon});'374 )375 body_lines.append(376 f' way["{k}"="{v}"]{religion_filter}'377 f'(around:{radius},{lat},{lon});'378 )379 body = "\n".join(body_lines)380 return (381 f'[out:json][timeout:25];\n'382 f'(\n'383 f'{body}\n'384 f');\n'385 f'out center {limit};\n'386 )387 388 389def build_overpass_bbox(tag_key, tag_val, south, west, north, east, limit,390 religion=None, tag_pairs=None):391 """Build an Overpass QL query for POIs within a bounding box.392 393 See ``build_overpass_nearby`` for ``tag_pairs`` semantics.394 """395 pairs = tag_pairs if tag_pairs else [(tag_key, tag_val)]396 religion_filter = ""397 if religion:398 religion_filter = f'["religion"="{religion}"]'399 body_lines = []400 for k, v in pairs:401 body_lines.append(402 f' node["{k}"="{v}"]{religion_filter}'403 f'({south},{west},{north},{east});'404 )405 body_lines.append(406 f' way["{k}"="{v}"]{religion_filter}'407 f'({south},{west},{north},{east});'408 )409 body = "\n".join(body_lines)410 return (411 f'[out:json][timeout:25];\n'412 f'(\n'413 f'{body}\n'414 f');\n'415 f'out center {limit};\n'416 )417 418 419def parse_overpass_elements(elements, ref_lat=None, ref_lon=None):420 """421 Parse Overpass elements into a clean list of POI dicts.422 If ref_lat/ref_lon are provided, computes distance and sorts by it.423 """424 places = []425 for el in elements:426 # Ways have a "center" sub-dict; nodes have lat/lon directly427 if el["type"] == "way":428 center = el.get("center", {})429 el_lat = center.get("lat")430 el_lon = center.get("lon")431 else:432 el_lat = el.get("lat")433 el_lon = el.get("lon")434 435 if el_lat is None or el_lon is None:436 continue437 438 tags = el.get("tags", {})439 name = tags.get("name") or tags.get("name:en") or ""440 441 # Build a short address from available tags442 addr_parts = []443 for part_key in ("addr:housenumber", "addr:street", "addr:city"):444 val = tags.get(part_key)445 if val:446 addr_parts.append(val)447 address_str = ", ".join(addr_parts) if addr_parts else ""448 449 place = {450 "name": name,451 "address": address_str,452 "lat": el_lat,453 "lon": el_lon,454 "osm_type": el.get("type", ""),455 "osm_id": el.get("id", ""),456 # Clickable Google Maps link so the agent can render a tap-to-open457 # URL in chat without composing one downstream.458 "maps_url": f"https://www.google.com/maps/search/?api=1&query={el_lat},{el_lon}",459 "tags": {460 k: v for k, v in tags.items()461 if k not in {"name", "name:en",462 "addr:housenumber", "addr:street", "addr:city"}463 },464 }465 466 # Promote commonly-useful tags to top-level fields so agents can467 # reference them without digging into the raw ``tags`` dict.468 for src_key, dst_key in (469 ("cuisine", "cuisine"),470 ("opening_hours", "hours"),471 ("phone", "phone"),472 ("website", "website"),473 ):474 val = tags.get(src_key)475 if val:476 place[dst_key] = val477 478 if ref_lat is not None and ref_lon is not None:479 dist_m = haversine_m(ref_lat, ref_lon, el_lat, el_lon)480 place["distance_m"] = round(dist_m, 1)481 # With a reference point we can also hand back a directions URL.482 place["directions_url"] = (483 f"https://www.google.com/maps/dir/?api=1"484 f"&origin={ref_lat},{ref_lon}"485 f"&destination={el_lat},{el_lon}"486 )487 488 places.append(place)489 490 # Sort by distance if available491 if places and "distance_m" in places[0]:492 places.sort(key=lambda p: p["distance_m"])493 494 return places495 496 497# ---------------------------------------------------------------------------498# Command: search499# ---------------------------------------------------------------------------500 501def cmd_search(args):502 """Geocode a place name and return top results."""503 query = " ".join(args.query)504 raw = nominatim_search(query, limit=5)505 506 if not raw:507 print_json({508 "query": query,509 "results": [],510 "count": 0,511 "data_source": DATA_SOURCE,512 })513 return514 515 results = []516 for item in raw:517 bb = item.get("boundingbox", [])518 results.append({519 "name": item.get("name") or item.get("display_name", ""),520 "display_name": item.get("display_name", ""),521 "lat": float(item["lat"]),522 "lon": float(item["lon"]),523 "type": item.get("type", ""),524 "category": item.get("category", ""),525 "osm_type": item.get("osm_type", ""),526 "osm_id": item.get("osm_id", ""),527 "bounding_box": {528 "min_lat": float(bb[0]) if len(bb) > 0 else None,529 "max_lat": float(bb[1]) if len(bb) > 1 else None,530 "min_lon": float(bb[2]) if len(bb) > 2 else None,531 "max_lon": float(bb[3]) if len(bb) > 3 else None,532 },533 "importance": item.get("importance"),534 })535 536 print_json({537 "query": query,538 "results": results,539 "count": len(results),540 "data_source": DATA_SOURCE,541 })542 543 544# ---------------------------------------------------------------------------545# Command: reverse546# ---------------------------------------------------------------------------547 548def cmd_reverse(args):549 """Reverse geocode coordinates to a human-readable address."""550 try:551 lat = float(args.lat)552 lon = float(args.lon)553 except ValueError:554 error_exit("LAT and LON must be numeric values.")555 556 if not (-90 <= lat <= 90):557 error_exit("Latitude must be between -90 and 90.")558 if not (-180 <= lon <= 180):559 error_exit("Longitude must be between -180 and 180.")560 561 data = nominatim_reverse(lat, lon)562 563 if "error" in data:564 error_exit(f"Reverse geocode failed: {data['error']}")565 566 address = data.get("address", {})567 568 print_json({569 "lat": lat,570 "lon": lon,571 "display_name": data.get("display_name", ""),572 "address": {573 "house_number": address.get("house_number", ""),574 "road": address.get("road", ""),575 "neighbourhood": address.get("neighbourhood", ""),576 "suburb": address.get("suburb", ""),577 "city": (address.get("city")578 or address.get("town")579 or address.get("village", "")),580 "county": address.get("county", ""),581 "state": address.get("state", ""),582 "postcode": address.get("postcode", ""),583 "country": address.get("country", ""),584 "country_code": address.get("country_code", ""),585 },586 "osm_type": data.get("osm_type", ""),587 "osm_id": data.get("osm_id", ""),588 "data_source": DATA_SOURCE,589 })590 591 592# ---------------------------------------------------------------------------593# Command: nearby594# ---------------------------------------------------------------------------595 596def cmd_nearby(args):597 """Find nearby POIs using the Overpass API.598 599 Accepts either explicit coordinates (``lat``/``lon``) or a free-form600 address via ``--near`` (auto-geocoded through Nominatim). Supports601 multiple categories in one call — results are merged, deduplicated602 by ``osm_type+osm_id``, sorted by distance.603 """604 # Resolve the center point. --near takes precedence if provided so the605 # agent can ask "cafes near Times Square" in one command without having606 # to geocode first.607 if getattr(args, "near", None):608 near_query = " ".join(args.near).strip() if isinstance(args.near, list) else str(args.near).strip()609 if not near_query:610 error_exit("--near must be a non-empty address or place name.")611 lat, lon, _ = geocode_single(near_query)612 else:613 try:614 lat = float(args.lat)615 lon = float(args.lon)616 except (TypeError, ValueError):617 error_exit("Provide numeric LAT and LON, or use --near \"<address>\".")618 619 # Categories: support both legacy single positional ``category`` and the620 # new repeatable ``--category`` flag. Users can ask for multiple place621 # types in one query.622 categories = []623 if getattr(args, "category_list", None):624 categories.extend(args.category_list)625 if getattr(args, "category", None):626 categories.append(args.category)627 # Deduplicate, preserve order, lower-case.628 categories = list(dict.fromkeys(c.lower() for c in categories if c))629 if not categories:630 error_exit("Provide at least one category (positional or --category).")631 unknown = [c for c in categories if c not in CATEGORY_TAGS]632 if unknown:633 error_exit(634 f"Unknown categor{'ies' if len(unknown) > 1 else 'y'} "635 f"{', '.join(repr(c) for c in unknown)}. "636 f"Valid categories: {', '.join(VALID_CATEGORIES)}"637 )638 639 radius = int(args.radius)640 limit = int(args.limit)641 if radius <= 0:642 error_exit("Radius must be a positive integer (metres).")643 if limit <= 0:644 error_exit("Limit must be a positive integer.")645 646 # Query each category against the Overpass fallback chain, merge results,647 # dedupe by OSM identity so POIs tagged under multiple categories don't648 # appear twice.649 merged = {}650 for category in categories:651 tag_pairs = _tags_for(category)652 religion = RELIGION_FILTER.get(category)653 query = build_overpass_nearby(None, None, lat, lon, radius, limit,654 religion=religion, tag_pairs=tag_pairs)655 raw = overpass_query(query)656 elements = raw.get("elements", [])657 for place in parse_overpass_elements(elements, ref_lat=lat, ref_lon=lon):658 place["category"] = category659 key = (place.get("osm_type", ""), place.get("osm_id", ""))660 # Prefer the entry that actually has a distance_m attached (first661 # pass through the ref_lat/ref_lon branch), then first-seen wins.662 if key not in merged:663 merged[key] = place664 665 # Sort merged by distance when we have ref lat/lon, then cap at ``limit``.666 places = sorted(667 merged.values(),668 key=lambda p: p.get("distance_m", float("inf")),669 )[:limit]670 671 print_json({672 "center_lat": lat,673 "center_lon": lon,674 "categories": categories,675 "radius_m": radius,676 "count": len(places),677 "results": places,678 "data_source": DATA_SOURCE,679 })680 681 682# ---------------------------------------------------------------------------683# Command: distance684# ---------------------------------------------------------------------------685 686def cmd_distance(args):687 """Calculate road distance and travel time between two places."""688 origin_query = " ".join(args.origin)689 destination_query = " ".join(args.to)690 mode = args.mode.lower()691 692 if mode not in OSRM_PROFILES:693 error_exit(f"Invalid mode '{mode}'. Choose from: {', '.join(OSRM_PROFILES)}")694 695 # Geocode origin and destination696 o_lat, o_lon, o_name = geocode_single(origin_query)697 d_lat, d_lon, d_name = geocode_single(destination_query)698 699 profile = OSRM_PROFILES[mode]700 url = (701 f"{OSRM_BASE}/{profile}/"702 f"{o_lon},{o_lat};{d_lon},{d_lat}"703 f"?overview=false&steps=false"704 )705 706 osrm_data = http_get(url)707 708 if osrm_data.get("code") != "Ok":709 error_exit(710 f"OSRM routing failed: "711 f"{osrm_data.get('message', osrm_data.get('code', 'unknown error'))}"712 )713 714 routes = osrm_data.get("routes", [])715 if not routes:716 error_exit("No route found between the two locations.")717 718 route = routes[0]719 distance_m = route.get("distance", 0)720 duration_s = route.get("duration", 0)721 distance_km = round(distance_m / 1000, 3)722 duration_min = round(duration_s / 60, 2)723 724 # Straight-line distance for reference725 straight_m = haversine_m(o_lat, o_lon, d_lat, d_lon)726 727 print_json({728 "origin": {729 "query": origin_query,730 "display_name": o_name,731 "lat": o_lat,732 "lon": o_lon,733 },734 "destination": {735 "query": destination_query,736 "display_name": d_name,737 "lat": d_lat,738 "lon": d_lon,739 },740 "mode": mode,741 "distance_km": distance_km,742 "distance_m": round(distance_m, 1),743 "duration_minutes": duration_min,744 "duration_seconds": round(duration_s, 1),745 "straight_line_km": round(straight_m / 1000, 3),746 "data_source": DATA_SOURCE,747 })748 749 750# ---------------------------------------------------------------------------751# Command: directions752# ---------------------------------------------------------------------------753 754def _format_duration(seconds):755 """Format seconds into a human-readable string."""756 if seconds < 60:757 return f"{round(seconds)}s"758 minutes = seconds / 60759 if minutes < 60:760 return f"{round(minutes, 1)} min"761 hours = int(minutes // 60)762 remaining = round(minutes % 60)763 return f"{hours}h {remaining}min"764 765 766def _format_distance(metres):767 """Format metres into a human-readable string."""768 if metres < 1000:769 return f"{round(metres)} m"770 return f"{round(metres / 1000, 2)} km"771 772 773def cmd_directions(args):774 """Get turn-by-turn directions between two places via OSRM."""775 origin_query = " ".join(args.origin)776 destination_query = " ".join(args.to)777 mode = args.mode.lower()778 779 if mode not in OSRM_PROFILES:780 error_exit(f"Invalid mode '{mode}'. Choose from: {', '.join(OSRM_PROFILES)}")781 782 # Geocode origin and destination783 o_lat, o_lon, o_name = geocode_single(origin_query)784 d_lat, d_lon, d_name = geocode_single(destination_query)785 786 profile = OSRM_PROFILES[mode]787 url = (788 f"{OSRM_BASE}/{profile}/"789 f"{o_lon},{o_lat};{d_lon},{d_lat}"790 f"?overview=false&steps=true"791 )792 793 osrm_data = http_get(url)794 795 if osrm_data.get("code") != "Ok":796 error_exit(797 f"OSRM routing failed: "798 f"{osrm_data.get('message', osrm_data.get('code', 'unknown error'))}"799 )800 801 routes = osrm_data.get("routes", [])802 if not routes:803 error_exit("No route found between the two locations.")804 805 route = routes[0]806 distance_m = route.get("distance", 0)807 duration_s = route.get("duration", 0)808 809 # Extract steps from all legs810 steps = []811 step_num = 0812 for leg in route.get("legs", []):813 for step in leg.get("steps", []):814 maneuver = step.get("maneuver", {})815 step_dist = step.get("distance", 0)816 step_dur = step.get("duration", 0)817 step_name = step.get("name", "")818 modifier = maneuver.get("modifier", "")819 m_type = maneuver.get("type", "")820 821 # Build instruction text822 if m_type == "depart":823 instruction = f"Depart on {step_name}" if step_name else "Depart"824 elif m_type == "arrive":825 instruction = "Arrive at destination"826 elif m_type == "turn":827 instruction = f"Turn {modifier} onto {step_name}" if step_name else f"Turn {modifier}"828 elif m_type == "new name":829 instruction = f"Continue onto {step_name}" if step_name else "Continue"830 elif m_type == "merge":831 instruction = f"Merge {modifier} onto {step_name}" if step_name else f"Merge {modifier}"832 elif m_type == "fork":833 instruction = f"Take the {modifier} fork onto {step_name}" if step_name else f"Take the {modifier} fork"834 elif m_type == "roundabout":835 instruction = f"Enter roundabout, exit onto {step_name}" if step_name else "Enter roundabout"836 elif m_type == "rotary":837 instruction = f"Enter rotary, exit onto {step_name}" if step_name else "Enter rotary"838 elif m_type == "end of road":839 instruction = f"At end of road, turn {modifier} onto {step_name}" if step_name else f"At end of road, turn {modifier}"840 elif m_type == "continue":841 instruction = f"Continue {modifier} on {step_name}" if step_name else f"Continue {modifier}"842 elif m_type == "on ramp":843 instruction = f"Take ramp onto {step_name}" if step_name else "Take ramp"844 elif m_type == "off ramp":845 instruction = f"Take exit onto {step_name}" if step_name else "Take exit"846 else:847 instruction = f"{m_type} {modifier} {step_name}".strip()848 849 step_num += 1850 steps.append({851 "step": step_num,852 "instruction": instruction,853 "distance": _format_distance(step_dist),854 "distance_m": round(step_dist, 1),855 "duration": _format_duration(step_dur),856 "duration_s": round(step_dur, 1),857 "road_name": step_name,858 "maneuver": m_type,859 })860 861 print_json({862 "origin": {863 "query": origin_query,864 "display_name": o_name,865 "lat": o_lat,866 "lon": o_lon,867 },868 "destination": {869 "query": destination_query,870 "display_name": d_name,871 "lat": d_lat,872 "lon": d_lon,873 },874 "mode": mode,875 "total_distance": _format_distance(distance_m),876 "total_distance_m": round(distance_m, 1),877 "total_duration": _format_duration(duration_s),878 "total_duration_s": round(duration_s, 1),879 "steps": steps,880 "step_count": len(steps),881 "data_source": DATA_SOURCE,882 })883 884 885# ---------------------------------------------------------------------------886# Command: timezone887# ---------------------------------------------------------------------------888 889def cmd_timezone(args):890 """891 Get timezone information for a lat/lon coordinate.892 893 Strategy:894 1. Try TimeAPI.io (free, no key, supports coordinate-based lookup).895 2. Fallback: derive UTC offset approximation from longitude.896 """897 try:898 lat = float(args.lat)899 lon = float(args.lon)900 except ValueError:901 error_exit("LAT and LON must be numeric values.")902 903 if not (-90 <= lat <= 90):904 error_exit("Latitude must be between -90 and 90.")905 if not (-180 <= lon <= 180):906 error_exit("Longitude must be between -180 and 180.")907 908 timezone_str = None909 timezone_src = None910 current_time = None911 utc_offset = None912 913 # --- Strategy 1: TimeAPI.io coordinate lookup ---914 try:915 params = {"latitude": lat, "longitude": lon}916 tz_data = http_get(TIMEAPI_BASE, params=params, silent=True)917 if isinstance(tz_data, dict):918 timezone_str = tz_data.get("timeZone")919 current_time = tz_data.get("currentLocalTime")920 # Build utc_offset from currentUtcOffset if available921 offset_info = tz_data.get("currentUtcOffset", {})922 if isinstance(offset_info, dict):923 oh = offset_info.get("hours", 0)924 om = abs(offset_info.get("minutes", 0))925 os_ = offset_info.get("seconds", 0)926 sign = "+" if oh >= 0 else "-"927 utc_offset = f"{sign}{abs(oh):02d}:{om:02d}"928 if os_:929 utc_offset = f"{utc_offset}:{os_:02d}"930 elif tz_data.get("standardUtcOffset"):931 offset_info2 = tz_data["standardUtcOffset"]932 if isinstance(offset_info2, dict):933 oh = offset_info2.get("hours", 0)934 om = abs(offset_info2.get("minutes", 0))935 os_ = offset_info2.get("seconds", 0)936 sign = "+" if oh >= 0 else "-"937 utc_offset = f"{sign}{abs(oh):02d}:{om:02d}"938 if os_:939 utc_offset = f"{utc_offset}:{os_:02d}"940 timezone_src = "timeapi.io"941 except (RuntimeError, KeyError, TypeError):942 pass # API may be down; continue to fallback943 944 # --- Strategy 2: longitude-based UTC offset approximation ---945 if not timezone_str:946 approx_offset_h = round(lon / 15)947 if approx_offset_h >= 0:948 utc_offset = f"+{approx_offset_h:02d}:00"949 else:950 utc_offset = f"-{abs(approx_offset_h):02d}:00"951 timezone_str = f"UTC{utc_offset}"952 timezone_src = "longitude approximation (longitude/15)"953 954 print_json({955 "lat": lat,956 "lon": lon,957 "timezone": timezone_str,958 "utc_offset": utc_offset,959 "current_time": current_time,960 "source": timezone_src,961 "data_source": DATA_SOURCE,962 })963 964 965# ---------------------------------------------------------------------------966# Command: bbox967# ---------------------------------------------------------------------------968 969def cmd_bbox(args):970 """Find POIs within a bounding box using the Overpass API."""971 try:972 lat1 = float(args.lat1)973 lon1 = float(args.lon1)974 lat2 = float(args.lat2)975 lon2 = float(args.lon2)976 except ValueError:977 error_exit("All coordinate arguments must be numeric values.")978 979 # Normalize: south/west < north/east980 south = min(lat1, lat2)981 north = max(lat1, lat2)982 west = min(lon1, lon2)983 east = max(lon1, lon2)984 985 category = args.category.lower()986 if category not in CATEGORY_TAGS:987 error_exit(988 f"Unknown category '{category}'. "989 f"Valid categories: {', '.join(VALID_CATEGORIES)}"990 )991 992 limit = int(args.limit)993 if limit <= 0:994 error_exit("Limit must be a positive integer.")995 996 tag_pairs = _tags_for(category)997 religion = RELIGION_FILTER.get(category)998 query = build_overpass_bbox(None, None, south, west, north, east,999 limit, religion=religion, tag_pairs=tag_pairs)1000 1001 raw = overpass_query(query)1002 1003 elements = raw.get("elements", [])1004 1005 # Use center of bbox as reference for distance sorting1006 center_lat = (south + north) / 21007 center_lon = (west + east) / 21008 places = parse_overpass_elements(elements, ref_lat=center_lat,1009 ref_lon=center_lon)1010 1011 for p in places:1012 p["category"] = category1013 1014 print_json({1015 "bounding_box": {1016 "south": south,1017 "west": west,1018 "north": north,1019 "east": east,1020 },1021 "category": category,1022 "count": len(places),1023 "results": places,1024 "data_source": DATA_SOURCE,1025 })1026 1027 1028# ---------------------------------------------------------------------------1029# Command: area1030# ---------------------------------------------------------------------------1031 1032def cmd_area(args):1033 """Get bounding box and area info for a named place."""1034 query = " ".join(args.place)1035 raw = nominatim_search(query, limit=1)1036 1037 if not raw:1038 error_exit(f"Could not find place: {query}")1039 1040 item = raw[0]1041 bb = item.get("boundingbox", [])1042 1043 if len(bb) < 4:1044 error_exit(f"No bounding box data available for: {query}")1045 1046 min_lat = float(bb[0])1047 max_lat = float(bb[1])1048 min_lon = float(bb[2])1049 max_lon = float(bb[3])1050 1051 # Approximate area in km² using the bounding box1052 # Width in km at the average latitude1053 avg_lat = (min_lat + max_lat) / 21054 height_km = haversine_m(min_lat, min_lon, max_lat, min_lon) / 10001055 width_km = haversine_m(avg_lat, min_lon, avg_lat, max_lon) / 10001056 approx_area_km2 = round(height_km * width_km, 3)1057 1058 print_json({1059 "query": query,1060 "display_name": item.get("display_name", ""),1061 "lat": float(item["lat"]),1062 "lon": float(item["lon"]),1063 "type": item.get("type", ""),1064 "category": item.get("category", ""),1065 "bounding_box": {1066 "south": min_lat,1067 "north": max_lat,1068 "west": min_lon,1069 "east": max_lon,1070 },1071 "dimensions": {1072 "width_km": round(width_km, 3),1073 "height_km": round(height_km, 3),1074 },1075 "approx_area_km2": approx_area_km2,1076 "osm_type": item.get("osm_type", ""),1077 "osm_id": item.get("osm_id", ""),1078 "data_source": DATA_SOURCE,1079 })1080 1081 1082# ---------------------------------------------------------------------------1083# CLI setup1084# ---------------------------------------------------------------------------1085 1086def build_parser():1087 parser = argparse.ArgumentParser(1088 prog="maps_client.py",1089 description=(1090 "CLI maps tool: geocoding, reverse geocoding, POI search, "1091 "routing, directions, timezone, and area lookup. "1092 "Powered by OpenStreetMap, OSRM, Overpass, and TimeAPI.io. "1093 "No API keys required."1094 ),1095 formatter_class=argparse.RawDescriptionHelpFormatter,1096 epilog=(1097 "Examples:\n"1098 " maps_client.py search Times Square\n"1099 " maps_client.py reverse 40.758 -73.985\n"1100 " maps_client.py nearby 40.758 -73.985 restaurant --radius 800\n"1101 " maps_client.py distance New York --to Los Angeles --mode driving\n"1102 " maps_client.py directions Paris --to Berlin --mode driving\n"1103 " maps_client.py timezone 48.8566 2.3522\n"1104 " maps_client.py bbox 40.70 -74.02 40.78 -73.95 restaurant\n"1105 " maps_client.py area Manhattan"1106 ),1107 )1108 sub = parser.add_subparsers(dest="command", required=True,1109 metavar="COMMAND")1110 1111 # -- search --1112 p_search = sub.add_parser(1113 "search",1114 help="Geocode a place name to coordinates.",1115 description="Search for a place by name and return coordinates and details.",1116 )1117 p_search.add_argument(1118 "query", nargs="+",1119 help="Place name or address to search.",1120 )1121 1122 # -- reverse --1123 p_reverse = sub.add_parser(1124 "reverse",1125 help="Reverse geocode coordinates to an address.",1126 description="Convert latitude/longitude coordinates to a human-readable address.",1127 )1128 p_reverse.add_argument("lat", help="Latitude (decimal degrees).")1129 p_reverse.add_argument("lon", help="Longitude (decimal degrees).")1130 1131 # -- nearby --1132 p_nearby = sub.add_parser(1133 "nearby",1134 help="Find nearby places of a given category.",1135 description=(1136 "Find points of interest near a location using the Overpass API.\n"1137 "Provide either LAT/LON, or use --near \"<address>\" to auto-geocode.\n"1138 "Categories can be specified positionally OR repeated via --category\n"1139 "to merge multiple types in one query (e.g. --category bar --category cafe).\n"1140 f"Categories: {', '.join(VALID_CATEGORIES)}"1141 ),1142 formatter_class=argparse.RawDescriptionHelpFormatter,1143 )1144 p_nearby.add_argument(1145 "lat", nargs="?", default=None,1146 help="Center latitude (decimal degrees). Omit if using --near.",1147 )1148 p_nearby.add_argument(1149 "lon", nargs="?", default=None,1150 help="Center longitude (decimal degrees). Omit if using --near.",1151 )1152 p_nearby.add_argument(1153 "category", nargs="?", default=None,1154 help="POI category (use --help for full list). Omit if using --category flags.",1155 )1156 p_nearby.add_argument(1157 "--near", nargs="+", metavar="PLACE",1158 help="Address, city, or landmark to search around (geocoded via Nominatim).",1159 )1160 p_nearby.add_argument(1161 "--category", action="append", dest="category_list", default=[],1162 metavar="CAT",1163 help="POI category (repeatable — adds a type to the search).",1164 )1165 p_nearby.add_argument(1166 "--radius", "-r",1167 default=500, type=int, metavar="METRES",1168 help="Search radius in metres (default: 500).",1169 )1170 p_nearby.add_argument(1171 "--limit", "-n",1172 default=10, type=int, metavar="N",1173 help="Maximum number of results (default: 10).",1174 )1175 1176 # -- distance --1177 p_dist = sub.add_parser(1178 "distance",1179 help="Calculate road distance and travel time.",1180 description=(1181 "Calculate road distance and estimated travel time between two places.\n"1182 "Example: maps_client.py distance New York --to Los Angeles"1183 ),1184 formatter_class=argparse.RawDescriptionHelpFormatter,1185 )1186 p_dist.add_argument(1187 "origin", nargs="+",1188 help="Origin address or place name.",1189 )1190 p_dist.add_argument(1191 "--to", nargs="+", required=True, metavar="DEST",1192 help="Destination address or place name (required).",1193 )1194 p_dist.add_argument(1195 "--mode", "-m",1196 default="driving",1197 choices=list(OSRM_PROFILES.keys()),1198 help="Travel mode (default: driving).",1199 )1200 1201 # -- directions --1202 p_dir = sub.add_parser(1203 "directions",1204 help="Get turn-by-turn directions between two places.",1205 description=(1206 "Get step-by-step navigation directions between two places.\n"1207 "Example: maps_client.py directions Paris --to Berlin --mode driving"1208 ),1209 formatter_class=argparse.RawDescriptionHelpFormatter,1210 )1211 p_dir.add_argument(1212 "origin", nargs="+",1213 help="Origin address or place name.",1214 )1215 p_dir.add_argument(1216 "--to", nargs="+", required=True, metavar="DEST",1217 help="Destination address or place name (required).",1218 )1219 p_dir.add_argument(1220 "--mode", "-m",1221 default="driving",1222 choices=list(OSRM_PROFILES.keys()),1223 help="Travel mode (default: driving).",1224 )1225 1226 # -- timezone --1227 p_tz = sub.add_parser(1228 "timezone",1229 help="Get timezone information for coordinates.",1230 description="Look up timezone and current local time for a lat/lon coordinate.",1231 )1232 p_tz.add_argument("lat", help="Latitude (decimal degrees).")1233 p_tz.add_argument("lon", help="Longitude (decimal degrees).")1234 1235 # -- bbox --1236 p_bbox = sub.add_parser(1237 "bbox",1238 help="Find POIs within a bounding box.",1239 description=(1240 "Search for points of interest within a geographic bounding box.\n"1241 "Tip: use the 'area' command to find bounding boxes for named places.\n"1242 f"Categories: {', '.join(VALID_CATEGORIES)}"1243 ),1244 formatter_class=argparse.RawDescriptionHelpFormatter,1245 )1246 p_bbox.add_argument("lat1", help="First corner latitude.")1247 p_bbox.add_argument("lon1", help="First corner longitude.")1248 p_bbox.add_argument("lat2", help="Second corner latitude.")1249 p_bbox.add_argument("lon2", help="Second corner longitude.")1250 p_bbox.add_argument("category", help="POI category to search for.")1251 p_bbox.add_argument(1252 "--limit", "-n",1253 default=20, type=int, metavar="N",1254 help="Maximum number of results (default: 20).",1255 )1256 1257 # -- area --1258 p_area = sub.add_parser(1259 "area",1260 help="Get bounding box and area info for a named place.",1261 description=(1262 "Look up a place by name and return its bounding box, dimensions, "1263 "and approximate area. Useful as input to the 'bbox' command."1264 ),1265 )1266 p_area.add_argument(1267 "place", nargs="+",1268 help="Place name to look up (e.g., 'Manhattan' or 'downtown Seattle').",1269 )1270 1271 return parser1272 1273 1274def main():1275 parser = build_parser()1276 args = parser.parse_args()1277 1278 dispatch = {1279 "search": cmd_search,1280 "reverse": cmd_reverse,1281 "nearby": cmd_nearby,1282 "distance": cmd_distance,1283 "directions": cmd_directions,1284 "timezone": cmd_timezone,1285 "bbox": cmd_bbox,1286 "area": cmd_area,1287 }1288 1289 handler = dispatch.get(args.command)1290 if handler is None:1291 error_exit(f"Unknown command: {args.command}")1292 1293 handler(args)1294 1295 1296if __name__ == "__main__":1297 main()1298