maps

Geocode, POIs, routes, timezones via OpenStreetMap/OSRM.

  • maps
  • geocoding
  • places
  • routing
  • distance
  • directions
  • nearby
  • location
  • openstreetmap
  • nominatim
  • overpass
  • osrm

Declared platforms: linux · macos · windows

Install
npx skills add 'https://github.com/NousResearch/hermes-agent/tree/main/skills/productivity/maps'
Download bundle ↓
main · 24fd22bScanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

scripts/maps_client.py

scripts/maps_client.pyBrowse 2 files
View on GitHub
← Back to SKILL.md
#!/usr/bin/env python3"""maps_client.py - CLI tool for maps, geocoding, routing, POI search, and more.Uses only Python stdlib. Data from OpenStreetMap/Nominatim, Overpass API, OSRM,and TimeAPI.io. Commands:  search     - Geocode a place name to coordinates  reverse    - Reverse geocode coordinates to an address  nearby     - Find nearby POIs by category  distance   - Road distance and travel time between two places  directions - Turn-by-turn directions between two places  timezone   - Timezone info for coordinates  bbox       - Find POIs within a bounding box  area       - Get bounding box and area info for a named place""" import argparseimport jsonimport mathimport sysimport timeimport urllib.errorimport urllib.parseimport urllib.request # ---------------------------------------------------------------------------# Constants# --------------------------------------------------------------------------- USER_AGENT = "HermesAgent/1.0 (contact: hermes@agent.ai)"DATA_SOURCE = "OpenStreetMap/Nominatim" NOMINATIM_SEARCH  = "https://nominatim.openstreetmap.org/search"NOMINATIM_REVERSE = "https://nominatim.openstreetmap.org/reverse"# Public Overpass endpoints. We try them in order so a single server# outage doesn't break the skill — kumi.systems is a well-known mirror.OVERPASS_URLS = [    "https://overpass-api.de/api/interpreter",    "https://overpass.kumi.systems/api/interpreter",]# Backward-compat alias for any caller that imports OVERPASS_API directly.OVERPASS_API      = OVERPASS_URLS[0]OSRM_BASE         = "https://router.project-osrm.org/route/v1"TIMEAPI_BASE      = "https://timeapi.io/api/timezone/coordinate" # Seconds to sleep between Nominatim requests (ToS requirement)NOMINATIM_RATE_LIMIT = 1.0 # Maximum retries for HTTP errorsMAX_RETRIES = 3RETRY_DELAY = 2.0  # seconds # Category -> (OSM tag key, OSM tag value)CATEGORY_TAGS = {    # Food & Drink    "restaurant":        ("amenity", "restaurant"),    "cafe":              ("amenity", "cafe"),    "bar":               ("amenity", "bar"),    # bakery is tagged as shop=bakery in the OSM wiki, but some mappers use    # amenity=bakery. Search both so small indie bakeries aren't missed.    "bakery":            [("shop", "bakery"), ("amenity", "bakery")],    "convenience_store": ("shop",    "convenience"),    # Health    "hospital":          ("amenity", "hospital"),    "pharmacy":          ("amenity", "pharmacy"),    "dentist":           ("amenity", "dentist"),    "doctor":            ("amenity", "doctors"),    "veterinary":        ("amenity", "veterinary"),    # Accommodation    "hotel":             ("tourism", "hotel"),    "guest_house":       ("tourism", "guest_house"),    "camp_site":         ("tourism", "camp_site"),    # Shopping & Services    "supermarket":       ("shop",    "supermarket"),    "bookshop":          ("shop",    "books"),    "laundry":           ("shop",    "laundry"),    # Finance    "atm":               ("amenity", "atm"),    "bank":              ("amenity", "bank"),    # Transport    "gas_station":       ("amenity", "fuel"),    "parking":           ("amenity", "parking"),    "airport":           ("aeroway", "aerodrome"),    "train_station":     ("railway", "station"),    "bus_stop":          ("highway", "bus_stop"),    "taxi":              ("amenity", "taxi"),    "car_wash":          ("amenity", "car_wash"),    "car_rental":        ("amenity", "car_rental"),    "bicycle_rental":    ("amenity", "bicycle_rental"),    # Culture & Entertainment    "museum":            ("tourism", "museum"),    "cinema":            ("amenity", "cinema"),    "theatre":           ("amenity", "theatre"),    "nightclub":         ("amenity", "nightclub"),    "zoo":               ("tourism", "zoo"),    # Education    "school":            ("amenity", "school"),    "university":        ("amenity", "university"),    "library":           ("amenity", "library"),    # Public Services    "police":            ("amenity", "police"),    "fire_station":      ("amenity", "fire_station"),    "post_office":       ("amenity", "post_office"),    # Religion    "church":            ("amenity", "place_of_worship"),  # refined by religion tag    "mosque":            ("amenity", "place_of_worship"),    "synagogue":         ("amenity", "place_of_worship"),    # Recreation    "park":              ("leisure", "park"),    "gym":               ("leisure", "fitness_centre"),    "swimming_pool":     ("leisure", "swimming_pool"),    "playground":        ("leisure", "playground"),    "stadium":           ("leisure", "stadium"),} # Religion-specific overrides for place_of_worship categoriesRELIGION_FILTER = {    "church":    "christian",    "mosque":    "muslim",    "synagogue": "jewish",} VALID_CATEGORIES = sorted(CATEGORY_TAGS.keys())  def _tags_for(category):    """Return the CATEGORY_TAGS entry as a list of (key, value) pairs.     Most categories map to a single (tag_key, tag_val) tuple, but some    (e.g. ``bakery``) are tagged under more than one OSM key and are    represented as a list of tuples. Normalise both forms to a list.    """    entry = CATEGORY_TAGS[category]    if isinstance(entry, list):        return list(entry)    return [entry] OSRM_PROFILES = {    "driving": "driving",    "walking": "foot",    "cycling": "bike",} # ---------------------------------------------------------------------------# Output helpers# --------------------------------------------------------------------------- def print_json(data):    """Print data as pretty-printed JSON to stdout."""    print(json.dumps(data, indent=2, ensure_ascii=False))  def error_exit(message, code=1):    """Print an error result as JSON and exit."""    print_json({"error": message, "status": "error"})    sys.exit(code)  # ---------------------------------------------------------------------------# HTTP helpers# --------------------------------------------------------------------------- def http_get(url, params=None, retries=MAX_RETRIES, silent=False):    """    Perform an HTTP GET request, returning parsed JSON.    Adds the required User-Agent header. Retries on transient errors.    If silent=True, raises RuntimeError instead of calling error_exit.    """    if params:        url = url + "?" + urllib.parse.urlencode(params)     req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})     last_error = None    for attempt in range(1, retries + 1):        try:            with urllib.request.urlopen(req, timeout=15) as resp:                raw = resp.read().decode("utf-8")                return json.loads(raw)        except urllib.error.HTTPError as exc:            last_error = f"HTTP {exc.code}: {exc.reason} for {url}"            if exc.code in {429, 503, 502, 504}:                time.sleep(RETRY_DELAY * attempt)            else:                if silent:                    raise RuntimeError(last_error)                error_exit(last_error)        except urllib.error.URLError as exc:            last_error = f"URL error: {exc.reason}"            time.sleep(RETRY_DELAY * attempt)        except json.JSONDecodeError as exc:            last_error = f"JSON parse error: {exc}"            time.sleep(RETRY_DELAY * attempt)     msg = f"Request failed after {retries} attempts. Last error: {last_error}"    if silent:        raise RuntimeError(msg)    error_exit(msg)  def http_get_text(url, params=None, retries=MAX_RETRIES, silent=False):    """    Like http_get but returns raw text instead of parsed JSON.    Useful for APIs that may return non-JSON responses.    """    if params:        url = url + "?" + urllib.parse.urlencode(params)     req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})     last_error = None    for attempt in range(1, retries + 1):        try:            with urllib.request.urlopen(req, timeout=15) as resp:                return resp.read().decode("utf-8")        except urllib.error.HTTPError as exc:            last_error = f"HTTP {exc.code}: {exc.reason} for {url}"            if exc.code in {429, 503, 502, 504}:                time.sleep(RETRY_DELAY * attempt)            else:                if silent:                    raise RuntimeError(last_error)                error_exit(last_error)        except urllib.error.URLError as exc:            last_error = f"URL error: {exc.reason}"            time.sleep(RETRY_DELAY * attempt)     msg = f"Request failed after {retries} attempts. Last error: {last_error}"    if silent:        raise RuntimeError(msg)    error_exit(msg)  def http_post(url, data_str, retries=MAX_RETRIES):    """    Perform an HTTP POST with a plain-text body (for Overpass QL).    Returns parsed JSON.    """    encoded = data_str.encode("utf-8")    req = urllib.request.Request(        url,        data=encoded,        headers={            "User-Agent": USER_AGENT,            "Content-Type": "application/x-www-form-urlencoded",        },    )     last_error = None    for attempt in range(1, retries + 1):        try:            with urllib.request.urlopen(req, timeout=30) as resp:                raw = resp.read().decode("utf-8")                return json.loads(raw)        except urllib.error.HTTPError as exc:            last_error = f"HTTP {exc.code}: {exc.reason}"            if exc.code in {429, 503, 502, 504}:                time.sleep(RETRY_DELAY * attempt)            else:                error_exit(last_error)        except urllib.error.URLError as exc:            last_error = f"URL error: {exc.reason}"            time.sleep(RETRY_DELAY * attempt)        except json.JSONDecodeError as exc:            last_error = f"JSON parse error: {exc}"            time.sleep(RETRY_DELAY * attempt)     error_exit(f"POST failed after {retries} attempts. Last error: {last_error}")  def overpass_query(query):    """POST an Overpass QL query, trying each URL in OVERPASS_URLS in turn.     A single public Overpass mirror can be rate-limited or down; trying the    next mirror before giving up turns a flaky outage into a retry. Returns    parsed JSON. Falls through to error_exit if every mirror fails.    """    post_data = "data=" + urllib.parse.quote(query)    last_error = None    for url in OVERPASS_URLS:        try:            return http_post(url, post_data, retries=1)        except SystemExit:            # error_exit inside http_post — keep trying the next mirror.            last_error = f"mirror {url} exhausted retries"            continue        except Exception as exc:            last_error = f"{url}: {exc}"            continue    error_exit(        f"All Overpass mirrors failed. Last error: {last_error or 'unknown'}"    )  # ---------------------------------------------------------------------------# Geo math# --------------------------------------------------------------------------- def haversine_m(lat1, lon1, lat2, lon2):    """Return distance in metres between two lat/lon points (Haversine)."""    R = 6_371_000  # Earth mean radius in metres    phi1 = math.radians(lat1)    phi2 = math.radians(lat2)    dphi = math.radians(lat2 - lat1)    dlam = math.radians(lon2 - lon1)    a = (math.sin(dphi / 2) ** 2         + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2)    return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))  # ---------------------------------------------------------------------------# Nominatim helpers# --------------------------------------------------------------------------- def nominatim_search(query, limit=5):    """Geocode a free-text query. Returns list of result dicts."""    params = {        "q":              query,        "format":         "json",        "limit":          limit,        "addressdetails": 1,    }    time.sleep(NOMINATIM_RATE_LIMIT)    return http_get(NOMINATIM_SEARCH, params=params)  def nominatim_reverse(lat, lon):    """Reverse geocode lat/lon. Returns a single result dict."""    params = {        "lat":            lat,        "lon":            lon,        "format":         "json",        "addressdetails": 1,    }    time.sleep(NOMINATIM_RATE_LIMIT)    return http_get(NOMINATIM_REVERSE, params=params)  def geocode_single(query):    """    Geocode a query and return (lat, lon, display_name).    Exits with error if nothing found.    """    results = nominatim_search(query, limit=1)    if not results:        error_exit(f"Could not geocode: {query}")    r = results[0]    return float(r["lat"]), float(r["lon"]), r.get("display_name", query)  # ---------------------------------------------------------------------------# Overpass helpers# --------------------------------------------------------------------------- def build_overpass_nearby(tag_key, tag_val, lat, lon, radius, limit,                          religion=None, tag_pairs=None):    """Build an Overpass QL query for nearby POIs around a point.     If ``tag_pairs`` is provided, the query unions across every    ``(key, value)`` pair (used for categories like ``bakery`` that are    tagged under more than one OSM key). Otherwise falls back to the    single ``tag_key``/``tag_val`` pair for back-compat.    """    pairs = tag_pairs if tag_pairs else [(tag_key, tag_val)]    religion_filter = ""    if religion:        religion_filter = f'["religion"="{religion}"]'    body_lines = []    for k, v in pairs:        body_lines.append(            f'  node["{k}"="{v}"]{religion_filter}'            f'(around:{radius},{lat},{lon});'        )        body_lines.append(            f'  way["{k}"="{v}"]{religion_filter}'            f'(around:{radius},{lat},{lon});'        )    body = "\n".join(body_lines)    return (        f'[out:json][timeout:25];\n'        f'(\n'        f'{body}\n'        f');\n'        f'out center {limit};\n'    )  def build_overpass_bbox(tag_key, tag_val, south, west, north, east, limit,                        religion=None, tag_pairs=None):    """Build an Overpass QL query for POIs within a bounding box.     See ``build_overpass_nearby`` for ``tag_pairs`` semantics.    """    pairs = tag_pairs if tag_pairs else [(tag_key, tag_val)]    religion_filter = ""    if religion:        religion_filter = f'["religion"="{religion}"]'    body_lines = []    for k, v in pairs:        body_lines.append(            f'  node["{k}"="{v}"]{religion_filter}'            f'({south},{west},{north},{east});'        )        body_lines.append(            f'  way["{k}"="{v}"]{religion_filter}'            f'({south},{west},{north},{east});'        )    body = "\n".join(body_lines)    return (        f'[out:json][timeout:25];\n'        f'(\n'        f'{body}\n'        f');\n'        f'out center {limit};\n'    )  def parse_overpass_elements(elements, ref_lat=None, ref_lon=None):    """    Parse Overpass elements into a clean list of POI dicts.    If ref_lat/ref_lon are provided, computes distance and sorts by it.    """    places = []    for el in elements:        # Ways have a "center" sub-dict; nodes have lat/lon directly        if el["type"] == "way":            center = el.get("center", {})            el_lat = center.get("lat")            el_lon = center.get("lon")        else:            el_lat = el.get("lat")            el_lon = el.get("lon")         if el_lat is None or el_lon is None:            continue         tags = el.get("tags", {})        name = tags.get("name") or tags.get("name:en") or ""         # Build a short address from available tags        addr_parts = []        for part_key in ("addr:housenumber", "addr:street", "addr:city"):            val = tags.get(part_key)            if val:                addr_parts.append(val)        address_str = ", ".join(addr_parts) if addr_parts else ""         place = {            "name":     name,            "address":  address_str,            "lat":      el_lat,            "lon":      el_lon,            "osm_type": el.get("type", ""),            "osm_id":   el.get("id", ""),            # Clickable Google Maps link so the agent can render a tap-to-open            # URL in chat without composing one downstream.            "maps_url": f"https://www.google.com/maps/search/?api=1&query={el_lat},{el_lon}",            "tags": {                k: v for k, v in tags.items()                if k not in {"name", "name:en",                             "addr:housenumber", "addr:street", "addr:city"}            },        }         # Promote commonly-useful tags to top-level fields so agents can        # reference them without digging into the raw ``tags`` dict.        for src_key, dst_key in (            ("cuisine",        "cuisine"),            ("opening_hours",  "hours"),            ("phone",          "phone"),            ("website",        "website"),        ):            val = tags.get(src_key)            if val:                place[dst_key] = val         if ref_lat is not None and ref_lon is not None:            dist_m = haversine_m(ref_lat, ref_lon, el_lat, el_lon)            place["distance_m"] = round(dist_m, 1)            # With a reference point we can also hand back a directions URL.            place["directions_url"] = (                f"https://www.google.com/maps/dir/?api=1"                f"&origin={ref_lat},{ref_lon}"                f"&destination={el_lat},{el_lon}"            )         places.append(place)     # Sort by distance if available    if places and "distance_m" in places[0]:        places.sort(key=lambda p: p["distance_m"])     return places  # ---------------------------------------------------------------------------# Command: search# --------------------------------------------------------------------------- def cmd_search(args):    """Geocode a place name and return top results."""    query = " ".join(args.query)    raw   = nominatim_search(query, limit=5)     if not raw:        print_json({            "query":       query,            "results":     [],            "count":       0,            "data_source": DATA_SOURCE,        })        return     results = []    for item in raw:        bb = item.get("boundingbox", [])        results.append({            "name":         item.get("name") or item.get("display_name", ""),            "display_name": item.get("display_name", ""),            "lat":          float(item["lat"]),            "lon":          float(item["lon"]),            "type":         item.get("type", ""),            "category":     item.get("category", ""),            "osm_type":     item.get("osm_type", ""),            "osm_id":       item.get("osm_id", ""),            "bounding_box": {                "min_lat": float(bb[0]) if len(bb) > 0 else None,                "max_lat": float(bb[1]) if len(bb) > 1 else None,                "min_lon": float(bb[2]) if len(bb) > 2 else None,                "max_lon": float(bb[3]) if len(bb) > 3 else None,            },            "importance":   item.get("importance"),        })     print_json({        "query":       query,        "results":     results,        "count":       len(results),        "data_source": DATA_SOURCE,    })  # ---------------------------------------------------------------------------# Command: reverse# --------------------------------------------------------------------------- def cmd_reverse(args):    """Reverse geocode coordinates to a human-readable address."""    try:        lat = float(args.lat)        lon = float(args.lon)    except ValueError:        error_exit("LAT and LON must be numeric values.")     if not (-90 <= lat <= 90):        error_exit("Latitude must be between -90 and 90.")    if not (-180 <= lon <= 180):        error_exit("Longitude must be between -180 and 180.")     data = nominatim_reverse(lat, lon)     if "error" in data:        error_exit(f"Reverse geocode failed: {data['error']}")     address = data.get("address", {})     print_json({        "lat":          lat,        "lon":          lon,        "display_name": data.get("display_name", ""),        "address": {            "house_number":  address.get("house_number", ""),            "road":          address.get("road", ""),            "neighbourhood": address.get("neighbourhood", ""),            "suburb":        address.get("suburb", ""),            "city":          (address.get("city")                              or address.get("town")                              or address.get("village", "")),            "county":        address.get("county", ""),            "state":         address.get("state", ""),            "postcode":      address.get("postcode", ""),            "country":       address.get("country", ""),            "country_code":  address.get("country_code", ""),        },        "osm_type":    data.get("osm_type", ""),        "osm_id":      data.get("osm_id", ""),        "data_source": DATA_SOURCE,    })  # ---------------------------------------------------------------------------# Command: nearby# --------------------------------------------------------------------------- def cmd_nearby(args):    """Find nearby POIs using the Overpass API.     Accepts either explicit coordinates (``lat``/``lon``) or a free-form    address via ``--near`` (auto-geocoded through Nominatim). Supports    multiple categories in one call — results are merged, deduplicated    by ``osm_type+osm_id``, sorted by distance.    """    # Resolve the center point. --near takes precedence if provided so the    # agent can ask "cafes near Times Square" in one command without having    # to geocode first.    if getattr(args, "near", None):        near_query = " ".join(args.near).strip() if isinstance(args.near, list) else str(args.near).strip()        if not near_query:            error_exit("--near must be a non-empty address or place name.")        lat, lon, _ = geocode_single(near_query)    else:        try:            lat = float(args.lat)            lon = float(args.lon)        except (TypeError, ValueError):            error_exit("Provide numeric LAT and LON, or use --near \"<address>\".")     # Categories: support both legacy single positional ``category`` and the    # new repeatable ``--category`` flag. Users can ask for multiple place    # types in one query.    categories = []    if getattr(args, "category_list", None):        categories.extend(args.category_list)    if getattr(args, "category", None):        categories.append(args.category)    # Deduplicate, preserve order, lower-case.    categories = list(dict.fromkeys(c.lower() for c in categories if c))    if not categories:        error_exit("Provide at least one category (positional or --category).")    unknown = [c for c in categories if c not in CATEGORY_TAGS]    if unknown:        error_exit(            f"Unknown categor{'ies' if len(unknown) > 1 else 'y'} "            f"{', '.join(repr(c) for c in unknown)}. "            f"Valid categories: {', '.join(VALID_CATEGORIES)}"        )     radius = int(args.radius)    limit  = int(args.limit)    if radius <= 0:        error_exit("Radius must be a positive integer (metres).")    if limit <= 0:        error_exit("Limit must be a positive integer.")     # Query each category against the Overpass fallback chain, merge results,    # dedupe by OSM identity so POIs tagged under multiple categories don't    # appear twice.    merged = {}    for category in categories:        tag_pairs = _tags_for(category)        religion = RELIGION_FILTER.get(category)        query = build_overpass_nearby(None, None, lat, lon, radius, limit,                                      religion=religion, tag_pairs=tag_pairs)        raw = overpass_query(query)        elements = raw.get("elements", [])        for place in parse_overpass_elements(elements, ref_lat=lat, ref_lon=lon):            place["category"] = category            key = (place.get("osm_type", ""), place.get("osm_id", ""))            # Prefer the entry that actually has a distance_m attached (first            # pass through the ref_lat/ref_lon branch), then first-seen wins.            if key not in merged:                merged[key] = place     # Sort merged by distance when we have ref lat/lon, then cap at ``limit``.    places = sorted(        merged.values(),        key=lambda p: p.get("distance_m", float("inf")),    )[:limit]     print_json({        "center_lat":  lat,        "center_lon":  lon,        "categories":  categories,        "radius_m":    radius,        "count":       len(places),        "results":     places,        "data_source": DATA_SOURCE,    })  # ---------------------------------------------------------------------------# Command: distance# --------------------------------------------------------------------------- def cmd_distance(args):    """Calculate road distance and travel time between two places."""    origin_query      = " ".join(args.origin)    destination_query = " ".join(args.to)    mode              = args.mode.lower()     if mode not in OSRM_PROFILES:        error_exit(f"Invalid mode '{mode}'. Choose from: {', '.join(OSRM_PROFILES)}")     # Geocode origin and destination    o_lat, o_lon, o_name = geocode_single(origin_query)    d_lat, d_lon, d_name = geocode_single(destination_query)     profile = OSRM_PROFILES[mode]    url = (        f"{OSRM_BASE}/{profile}/"        f"{o_lon},{o_lat};{d_lon},{d_lat}"        f"?overview=false&steps=false"    )     osrm_data = http_get(url)     if osrm_data.get("code") != "Ok":        error_exit(            f"OSRM routing failed: "            f"{osrm_data.get('message', osrm_data.get('code', 'unknown error'))}"        )     routes = osrm_data.get("routes", [])    if not routes:        error_exit("No route found between the two locations.")     route        = routes[0]    distance_m   = route.get("distance", 0)    duration_s   = route.get("duration", 0)    distance_km  = round(distance_m / 1000, 3)    duration_min = round(duration_s / 60, 2)     # Straight-line distance for reference    straight_m = haversine_m(o_lat, o_lon, d_lat, d_lon)     print_json({        "origin": {            "query":        origin_query,            "display_name": o_name,            "lat":          o_lat,            "lon":          o_lon,        },        "destination": {            "query":        destination_query,            "display_name": d_name,            "lat":          d_lat,            "lon":          d_lon,        },        "mode":             mode,        "distance_km":      distance_km,        "distance_m":       round(distance_m, 1),        "duration_minutes": duration_min,        "duration_seconds": round(duration_s, 1),        "straight_line_km": round(straight_m / 1000, 3),        "data_source":      DATA_SOURCE,    })  # ---------------------------------------------------------------------------# Command: directions# --------------------------------------------------------------------------- def _format_duration(seconds):    """Format seconds into a human-readable string."""    if seconds < 60:        return f"{round(seconds)}s"    minutes = seconds / 60    if minutes < 60:        return f"{round(minutes, 1)} min"    hours = int(minutes // 60)    remaining = round(minutes % 60)    return f"{hours}h {remaining}min"  def _format_distance(metres):    """Format metres into a human-readable string."""    if metres < 1000:        return f"{round(metres)} m"    return f"{round(metres / 1000, 2)} km"  def cmd_directions(args):    """Get turn-by-turn directions between two places via OSRM."""    origin_query      = " ".join(args.origin)    destination_query = " ".join(args.to)    mode              = args.mode.lower()     if mode not in OSRM_PROFILES:        error_exit(f"Invalid mode '{mode}'. Choose from: {', '.join(OSRM_PROFILES)}")     # Geocode origin and destination    o_lat, o_lon, o_name = geocode_single(origin_query)    d_lat, d_lon, d_name = geocode_single(destination_query)     profile = OSRM_PROFILES[mode]    url = (        f"{OSRM_BASE}/{profile}/"        f"{o_lon},{o_lat};{d_lon},{d_lat}"        f"?overview=false&steps=true"    )     osrm_data = http_get(url)     if osrm_data.get("code") != "Ok":        error_exit(            f"OSRM routing failed: "            f"{osrm_data.get('message', osrm_data.get('code', 'unknown error'))}"        )     routes = osrm_data.get("routes", [])    if not routes:        error_exit("No route found between the two locations.")     route        = routes[0]    distance_m   = route.get("distance", 0)    duration_s   = route.get("duration", 0)     # Extract steps from all legs    steps = []    step_num = 0    for leg in route.get("legs", []):        for step in leg.get("steps", []):            maneuver = step.get("maneuver", {})            step_dist = step.get("distance", 0)            step_dur  = step.get("duration", 0)            step_name = step.get("name", "")            modifier  = maneuver.get("modifier", "")            m_type    = maneuver.get("type", "")             # Build instruction text            if m_type == "depart":                instruction = f"Depart on {step_name}" if step_name else "Depart"            elif m_type == "arrive":                instruction = "Arrive at destination"            elif m_type == "turn":                instruction = f"Turn {modifier} onto {step_name}" if step_name else f"Turn {modifier}"            elif m_type == "new name":                instruction = f"Continue onto {step_name}" if step_name else "Continue"            elif m_type == "merge":                instruction = f"Merge {modifier} onto {step_name}" if step_name else f"Merge {modifier}"            elif m_type == "fork":                instruction = f"Take the {modifier} fork onto {step_name}" if step_name else f"Take the {modifier} fork"            elif m_type == "roundabout":                instruction = f"Enter roundabout, exit onto {step_name}" if step_name else "Enter roundabout"            elif m_type == "rotary":                instruction = f"Enter rotary, exit onto {step_name}" if step_name else "Enter rotary"            elif m_type == "end of road":                instruction = f"At end of road, turn {modifier} onto {step_name}" if step_name else f"At end of road, turn {modifier}"            elif m_type == "continue":                instruction = f"Continue {modifier} on {step_name}" if step_name else f"Continue {modifier}"            elif m_type == "on ramp":                instruction = f"Take ramp onto {step_name}" if step_name else "Take ramp"            elif m_type == "off ramp":                instruction = f"Take exit onto {step_name}" if step_name else "Take exit"            else:                instruction = f"{m_type} {modifier} {step_name}".strip()             step_num += 1            steps.append({                "step":        step_num,                "instruction": instruction,                "distance":    _format_distance(step_dist),                "distance_m":  round(step_dist, 1),                "duration":    _format_duration(step_dur),                "duration_s":  round(step_dur, 1),                "road_name":   step_name,                "maneuver":    m_type,            })     print_json({        "origin": {            "query":        origin_query,            "display_name": o_name,            "lat":          o_lat,            "lon":          o_lon,        },        "destination": {            "query":        destination_query,            "display_name": d_name,            "lat":          d_lat,            "lon":          d_lon,        },        "mode":               mode,        "total_distance":     _format_distance(distance_m),        "total_distance_m":   round(distance_m, 1),        "total_duration":     _format_duration(duration_s),        "total_duration_s":   round(duration_s, 1),        "steps":              steps,        "step_count":         len(steps),        "data_source":        DATA_SOURCE,    })  # ---------------------------------------------------------------------------# Command: timezone# --------------------------------------------------------------------------- def cmd_timezone(args):    """    Get timezone information for a lat/lon coordinate.     Strategy:      1. Try TimeAPI.io (free, no key, supports coordinate-based lookup).      2. Fallback: derive UTC offset approximation from longitude.    """    try:        lat = float(args.lat)        lon = float(args.lon)    except ValueError:        error_exit("LAT and LON must be numeric values.")     if not (-90 <= lat <= 90):        error_exit("Latitude must be between -90 and 90.")    if not (-180 <= lon <= 180):        error_exit("Longitude must be between -180 and 180.")     timezone_str = None    timezone_src = None    current_time = None    utc_offset   = None     # --- Strategy 1: TimeAPI.io coordinate lookup ---    try:        params = {"latitude": lat, "longitude": lon}        tz_data = http_get(TIMEAPI_BASE, params=params, silent=True)        if isinstance(tz_data, dict):            timezone_str = tz_data.get("timeZone")            current_time = tz_data.get("currentLocalTime")            # Build utc_offset from currentUtcOffset if available            offset_info = tz_data.get("currentUtcOffset", {})            if isinstance(offset_info, dict):                oh = offset_info.get("hours", 0)                om = abs(offset_info.get("minutes", 0))                os_ = offset_info.get("seconds", 0)                sign = "+" if oh >= 0 else "-"                utc_offset = f"{sign}{abs(oh):02d}:{om:02d}"                if os_:                    utc_offset = f"{utc_offset}:{os_:02d}"            elif tz_data.get("standardUtcOffset"):                offset_info2 = tz_data["standardUtcOffset"]                if isinstance(offset_info2, dict):                    oh = offset_info2.get("hours", 0)                    om = abs(offset_info2.get("minutes", 0))                    os_ = offset_info2.get("seconds", 0)                    sign = "+" if oh >= 0 else "-"                    utc_offset = f"{sign}{abs(oh):02d}:{om:02d}"                    if os_:                        utc_offset = f"{utc_offset}:{os_:02d}"            timezone_src = "timeapi.io"    except (RuntimeError, KeyError, TypeError):        pass  # API may be down; continue to fallback     # --- Strategy 2: longitude-based UTC offset approximation ---    if not timezone_str:        approx_offset_h = round(lon / 15)        if approx_offset_h >= 0:            utc_offset = f"+{approx_offset_h:02d}:00"        else:            utc_offset = f"-{abs(approx_offset_h):02d}:00"        timezone_str = f"UTC{utc_offset}"        timezone_src = "longitude approximation (longitude/15)"     print_json({        "lat":          lat,        "lon":          lon,        "timezone":     timezone_str,        "utc_offset":   utc_offset,        "current_time": current_time,        "source":       timezone_src,        "data_source":  DATA_SOURCE,    })  # ---------------------------------------------------------------------------# Command: bbox# --------------------------------------------------------------------------- def cmd_bbox(args):    """Find POIs within a bounding box using the Overpass API."""    try:        lat1 = float(args.lat1)        lon1 = float(args.lon1)        lat2 = float(args.lat2)        lon2 = float(args.lon2)    except ValueError:        error_exit("All coordinate arguments must be numeric values.")     # Normalize: south/west < north/east    south = min(lat1, lat2)    north = max(lat1, lat2)    west  = min(lon1, lon2)    east  = max(lon1, lon2)     category = args.category.lower()    if category not in CATEGORY_TAGS:        error_exit(            f"Unknown category '{category}'. "            f"Valid categories: {', '.join(VALID_CATEGORIES)}"        )     limit = int(args.limit)    if limit <= 0:        error_exit("Limit must be a positive integer.")     tag_pairs = _tags_for(category)    religion = RELIGION_FILTER.get(category)    query = build_overpass_bbox(None, None, south, west, north, east,                                limit, religion=religion, tag_pairs=tag_pairs)     raw = overpass_query(query)     elements = raw.get("elements", [])     # Use center of bbox as reference for distance sorting    center_lat = (south + north) / 2    center_lon = (west + east) / 2    places = parse_overpass_elements(elements, ref_lat=center_lat,                                     ref_lon=center_lon)     for p in places:        p["category"] = category     print_json({        "bounding_box": {            "south": south,            "west":  west,            "north": north,            "east":  east,        },        "category":    category,        "count":       len(places),        "results":     places,        "data_source": DATA_SOURCE,    })  # ---------------------------------------------------------------------------# Command: area# --------------------------------------------------------------------------- def cmd_area(args):    """Get bounding box and area info for a named place."""    query = " ".join(args.place)    raw = nominatim_search(query, limit=1)     if not raw:        error_exit(f"Could not find place: {query}")     item = raw[0]    bb = item.get("boundingbox", [])     if len(bb) < 4:        error_exit(f"No bounding box data available for: {query}")     min_lat = float(bb[0])    max_lat = float(bb[1])    min_lon = float(bb[2])    max_lon = float(bb[3])     # Approximate area in km² using the bounding box    # Width in km at the average latitude    avg_lat = (min_lat + max_lat) / 2    height_km = haversine_m(min_lat, min_lon, max_lat, min_lon) / 1000    width_km  = haversine_m(avg_lat, min_lon, avg_lat, max_lon) / 1000    approx_area_km2 = round(height_km * width_km, 3)     print_json({        "query":        query,        "display_name": item.get("display_name", ""),        "lat":          float(item["lat"]),        "lon":          float(item["lon"]),        "type":         item.get("type", ""),        "category":     item.get("category", ""),        "bounding_box": {            "south": min_lat,            "north": max_lat,            "west":  min_lon,            "east":  max_lon,        },        "dimensions": {            "width_km":  round(width_km, 3),            "height_km": round(height_km, 3),        },        "approx_area_km2": approx_area_km2,        "osm_type":        item.get("osm_type", ""),        "osm_id":          item.get("osm_id", ""),        "data_source":     DATA_SOURCE,    })  # ---------------------------------------------------------------------------# CLI setup# --------------------------------------------------------------------------- def build_parser():    parser = argparse.ArgumentParser(        prog="maps_client.py",        description=(            "CLI maps tool: geocoding, reverse geocoding, POI search, "            "routing, directions, timezone, and area lookup. "            "Powered by OpenStreetMap, OSRM, Overpass, and TimeAPI.io. "            "No API keys required."        ),        formatter_class=argparse.RawDescriptionHelpFormatter,        epilog=(            "Examples:\n"            "  maps_client.py search Times Square\n"            "  maps_client.py reverse 40.758 -73.985\n"            "  maps_client.py nearby 40.758 -73.985 restaurant --radius 800\n"            "  maps_client.py distance New York --to Los Angeles --mode driving\n"            "  maps_client.py directions Paris --to Berlin --mode driving\n"            "  maps_client.py timezone 48.8566 2.3522\n"            "  maps_client.py bbox 40.70 -74.02 40.78 -73.95 restaurant\n"            "  maps_client.py area Manhattan"        ),    )    sub = parser.add_subparsers(dest="command", required=True,                                 metavar="COMMAND")     # -- search --    p_search = sub.add_parser(        "search",        help="Geocode a place name to coordinates.",        description="Search for a place by name and return coordinates and details.",    )    p_search.add_argument(        "query", nargs="+",        help="Place name or address to search.",    )     # -- reverse --    p_reverse = sub.add_parser(        "reverse",        help="Reverse geocode coordinates to an address.",        description="Convert latitude/longitude coordinates to a human-readable address.",    )    p_reverse.add_argument("lat", help="Latitude (decimal degrees).")    p_reverse.add_argument("lon", help="Longitude (decimal degrees).")     # -- nearby --    p_nearby = sub.add_parser(        "nearby",        help="Find nearby places of a given category.",        description=(            "Find points of interest near a location using the Overpass API.\n"            "Provide either LAT/LON, or use --near \"<address>\" to auto-geocode.\n"            "Categories can be specified positionally OR repeated via --category\n"            "to merge multiple types in one query (e.g. --category bar --category cafe).\n"            f"Categories: {', '.join(VALID_CATEGORIES)}"        ),        formatter_class=argparse.RawDescriptionHelpFormatter,    )    p_nearby.add_argument(        "lat", nargs="?", default=None,        help="Center latitude (decimal degrees). Omit if using --near.",    )    p_nearby.add_argument(        "lon", nargs="?", default=None,        help="Center longitude (decimal degrees). Omit if using --near.",    )    p_nearby.add_argument(        "category", nargs="?", default=None,        help="POI category (use --help for full list). Omit if using --category flags.",    )    p_nearby.add_argument(        "--near", nargs="+", metavar="PLACE",        help="Address, city, or landmark to search around (geocoded via Nominatim).",    )    p_nearby.add_argument(        "--category", action="append", dest="category_list", default=[],        metavar="CAT",        help="POI category (repeatable — adds a type to the search).",    )    p_nearby.add_argument(        "--radius", "-r",        default=500, type=int, metavar="METRES",        help="Search radius in metres (default: 500).",    )    p_nearby.add_argument(        "--limit", "-n",        default=10, type=int, metavar="N",        help="Maximum number of results (default: 10).",    )     # -- distance --    p_dist = sub.add_parser(        "distance",        help="Calculate road distance and travel time.",        description=(            "Calculate road distance and estimated travel time between two places.\n"            "Example: maps_client.py distance New York --to Los Angeles"        ),        formatter_class=argparse.RawDescriptionHelpFormatter,    )    p_dist.add_argument(        "origin", nargs="+",        help="Origin address or place name.",    )    p_dist.add_argument(        "--to", nargs="+", required=True, metavar="DEST",        help="Destination address or place name (required).",    )    p_dist.add_argument(        "--mode", "-m",        default="driving",        choices=list(OSRM_PROFILES.keys()),        help="Travel mode (default: driving).",    )     # -- directions --    p_dir = sub.add_parser(        "directions",        help="Get turn-by-turn directions between two places.",        description=(            "Get step-by-step navigation directions between two places.\n"            "Example: maps_client.py directions Paris --to Berlin --mode driving"        ),        formatter_class=argparse.RawDescriptionHelpFormatter,    )    p_dir.add_argument(        "origin", nargs="+",        help="Origin address or place name.",    )    p_dir.add_argument(        "--to", nargs="+", required=True, metavar="DEST",        help="Destination address or place name (required).",    )    p_dir.add_argument(        "--mode", "-m",        default="driving",        choices=list(OSRM_PROFILES.keys()),        help="Travel mode (default: driving).",    )     # -- timezone --    p_tz = sub.add_parser(        "timezone",        help="Get timezone information for coordinates.",        description="Look up timezone and current local time for a lat/lon coordinate.",    )    p_tz.add_argument("lat", help="Latitude (decimal degrees).")    p_tz.add_argument("lon", help="Longitude (decimal degrees).")     # -- bbox --    p_bbox = sub.add_parser(        "bbox",        help="Find POIs within a bounding box.",        description=(            "Search for points of interest within a geographic bounding box.\n"            "Tip: use the 'area' command to find bounding boxes for named places.\n"            f"Categories: {', '.join(VALID_CATEGORIES)}"        ),        formatter_class=argparse.RawDescriptionHelpFormatter,    )    p_bbox.add_argument("lat1", help="First corner latitude.")    p_bbox.add_argument("lon1", help="First corner longitude.")    p_bbox.add_argument("lat2", help="Second corner latitude.")    p_bbox.add_argument("lon2", help="Second corner longitude.")    p_bbox.add_argument("category", help="POI category to search for.")    p_bbox.add_argument(        "--limit", "-n",        default=20, type=int, metavar="N",        help="Maximum number of results (default: 20).",    )     # -- area --    p_area = sub.add_parser(        "area",        help="Get bounding box and area info for a named place.",        description=(            "Look up a place by name and return its bounding box, dimensions, "            "and approximate area. Useful as input to the 'bbox' command."        ),    )    p_area.add_argument(        "place", nargs="+",        help="Place name to look up (e.g., 'Manhattan' or 'downtown Seattle').",    )     return parser  def main():    parser = build_parser()    args   = parser.parse_args()     dispatch = {        "search":     cmd_search,        "reverse":    cmd_reverse,        "nearby":     cmd_nearby,        "distance":   cmd_distance,        "directions": cmd_directions,        "timezone":   cmd_timezone,        "bbox":       cmd_bbox,        "area":       cmd_area,    }     handler = dispatch.get(args.command)    if handler is None:        error_exit(f"Unknown command: {args.command}")     handler(args)  if __name__ == "__main__":    main()