hermes-agent/optional-skills/productivity/maps/scripts/maps_client.py
Mibayy 7fa01fafa5 feat: add maps skill (OpenStreetMap + Overpass + OSRM, no API key)
Adds a maps optional skill with 8 commands, 44 POI categories, and
zero external dependencies. Uses free open data: Nominatim, Overpass
API, OSRM, and TimeAPI.io.

Commands: search, reverse, nearby, distance, directions, timezone,
area, bbox.

Improvements over original PR #2015:
- Fixed directory structure (optional-skills/productivity/maps/)
- Fixed distance argparse (--to flag instead of broken dual nargs=+)
- Fixed timezone (TimeAPI.io instead of broken worldtimeapi heuristic)
- Expanded POI categories from 12 to 44
- Added directions command with turn-by-turn OSRM steps
- Added area command (bounding box + dimensions for a named place)
- Added bbox command (POI search within a geographic rectangle)
- Added 23 unit tests
- Improved haversine (atan2 for numerical stability)
- Comprehensive SKILL.md with workflow examples

Co-authored-by: Mibayy <Mibayy@users.noreply.github.com>
2026-04-19 05:19:22 -07:00

1143 lines
39 KiB
Python

#!/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 argparse
import json
import math
import os
import sys
import time
import urllib.error
import urllib.parse
import 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"
OVERPASS_API = "https://overpass-api.de/api/interpreter"
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 errors
MAX_RETRIES = 3
RETRY_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": ("shop", "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"),
# 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 categories
RELIGION_FILTER = {
"church": "christian",
"mosque": "muslim",
"synagogue": "jewish",
}
VALID_CATEGORIES = sorted(CATEGORY_TAGS.keys())
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}")
# ---------------------------------------------------------------------------
# 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):
"""Build an Overpass QL query for nearby POIs around a point."""
religion_filter = ""
if religion:
religion_filter = f'["religion"="{religion}"]'
return (
f'[out:json][timeout:25];\n'
f'(\n'
f' node["{tag_key}"="{tag_val}"]{religion_filter}'
f'(around:{radius},{lat},{lon});\n'
f' way["{tag_key}"="{tag_val}"]{religion_filter}'
f'(around:{radius},{lat},{lon});\n'
f');\n'
f'out center {limit};\n'
)
def build_overpass_bbox(tag_key, tag_val, south, west, north, east, limit,
religion=None):
"""Build an Overpass QL query for POIs within a bounding box."""
religion_filter = ""
if religion:
religion_filter = f'["religion"="{religion}"]'
return (
f'[out:json][timeout:25];\n'
f'(\n'
f' node["{tag_key}"="{tag_val}"]{religion_filter}'
f'({south},{west},{north},{east});\n'
f' way["{tag_key}"="{tag_val}"]{religion_filter}'
f'({south},{west},{north},{east});\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", ""),
"tags": {
k: v for k, v in tags.items()
if k not in ("name", "name:en",
"addr:housenumber", "addr:street", "addr:city")
},
}
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)
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."""
try:
lat = float(args.lat)
lon = float(args.lon)
except ValueError:
error_exit("LAT and LON must be numeric values.")
category = args.category.lower()
if category not in CATEGORY_TAGS:
error_exit(
f"Unknown category '{category}'. "
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.")
tag_key, tag_val = CATEGORY_TAGS[category]
religion = RELIGION_FILTER.get(category)
query = build_overpass_nearby(tag_key, tag_val, lat, lon, radius, limit,
religion=religion)
post_data = "data=" + urllib.parse.quote(query)
raw = http_post(OVERPASS_API, post_data)
elements = raw.get("elements", [])
places = parse_overpass_elements(elements, ref_lat=lat, ref_lon=lon)
# Add category to each result
for p in places:
p["category"] = category
print_json({
"center_lat": lat,
"center_lon": lon,
"category": category,
"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}"
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))
sign = "+" if oh >= 0 else "-"
utc_offset = f"{sign}{abs(oh):02d}:{om: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_key, tag_val = CATEGORY_TAGS[category]
religion = RELIGION_FILTER.get(category)
query = build_overpass_bbox(tag_key, tag_val, south, west, north, east,
limit, religion=religion)
post_data = "data=" + urllib.parse.quote(query)
raw = http_post(OVERPASS_API, post_data)
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"
f"Categories: {', '.join(VALID_CATEGORIES)}"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p_nearby.add_argument("lat", help="Center latitude (decimal degrees).")
p_nearby.add_argument("lon", help="Center longitude (decimal degrees).")
p_nearby.add_argument(
"category",
help="POI category (use --help to see full list).",
)
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()