from __future__ import annotations import json import math import os import re import time from pathlib import Path from typing import Any from urllib.parse import quote import requests from .models import GeoPoint, RouteMatrix NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" OSRM_TABLE_URL = "https://router.project-osrm.org/table/v1/driving" CACHE_DIR = Path("cache") GEO_CACHE_PATH = CACHE_DIR / "geo_cache.json" BUILTIN_DEMO_CITIES = ("上海", "北京") DEMO_POINTS: dict[str, tuple[float, float, str]] = { "上海交通大学闵行校区": (31.0256, 121.4332, "上海交通大学闵行校区"), "交大闵行": (31.0256, 121.4332, "上海交通大学闵行校区"), "上海交大闵行校区": (31.0256, 121.4332, "上海交通大学闵行校区"), "外滩": (31.2400, 121.4900, "外滩"), "陆家嘴": (31.2381, 121.4970, "陆家嘴"), "人民广场": (31.2304, 121.4737, "人民广场"), "徐家汇": (31.1838, 121.4328, "徐家汇"), "豫园": (31.2272, 121.4922, "豫园"), "静安寺": (31.2230, 121.4454, "静安寺"), "南京东路": (31.2366, 121.4842, "南京东路"), "东方明珠": (31.2397, 121.4998, "东方明珠广播电视塔"), "上海虹桥站": (31.1945, 121.3188, "上海虹桥站"), "上海南站": (31.1546, 121.4296, "上海南站"), "复旦大学": (31.2989, 121.5038, "复旦大学邯郸校区"), "同济大学": (31.2820, 121.5062, "同济大学四平路校区"), "华东师范大学": (31.2282, 121.4038, "华东师范大学普陀校区"), "北京邮电大学": (39.9620, 116.3585, "北京邮电大学西土城路校区"), "北邮": (39.9620, 116.3585, "北京邮电大学西土城路校区"), "北京邮电大学西土城路校区": (39.9620, 116.3585, "北京邮电大学西土城路校区"), "国家大剧院": (39.9038, 116.3838, "国家大剧院"), "北京国家大剧院": (39.9038, 116.3838, "国家大剧院"), "鼓楼": (39.9404, 116.3972, "北京鼓楼"), "北京鼓楼": (39.9404, 116.3972, "北京鼓楼"), "鼓楼大街": (39.9470, 116.3938, "鼓楼大街"), "北海": (39.9255, 116.3895, "北海公园"), "北海公园": (39.9255, 116.3895, "北海公园"), "国家植物园": (39.9974, 116.2072, "国家植物园"), "北京国家植物园": (39.9974, 116.2072, "国家植物园"), "北京植物园": (39.9974, 116.2072, "国家植物园"), } class GeoToolError(RuntimeError): pass def geocode_places(places: list[str], city_hint: str = "", timeout: int = 12) -> list[GeoPoint]: cache = load_json_cache(GEO_CACHE_PATH) results: list[GeoPoint] = [] last_live_query_at = 0.0 for place in places: place = place.strip() if not place: raise GeoToolError("地点列表中存在空项。请删除空行,确保起点和每个目的地都有具体名称。") manual = parse_manual_point(place) if manual: point = manual results.append(point) continue demo = lookup_demo_point(place) if demo: lat, lon, display_name = demo point = GeoPoint(place, place, lat, lon, display_name, "built-in-demo") cache_key = normalize_key(place, city_hint) cache[cache_key] = point.__dict__ results.append(point) continue cache_key = normalize_key(place, city_hint) if cache_key in cache: cached = cache[cache_key] results.append(GeoPoint(**cached)) continue query = f"{place}, {city_hint}".strip(" ,") sleep_for_nominatim(last_live_query_at) last_live_query_at = time.time() try: point = geocode_with_nominatim(place, query, timeout) except GeoToolError: raise except requests.exceptions.Timeout as exc: raise GeoToolError(build_geocode_failure_message(place, query, "在线地理编码服务响应超时")) from exc except requests.exceptions.ConnectionError as exc: raise GeoToolError(build_geocode_failure_message(place, query, "无法连接在线地理编码服务")) from exc except requests.exceptions.HTTPError as exc: raise GeoToolError(build_geocode_failure_message(place, query, f"在线地理编码 HTTP 错误:{exc.response.status_code if exc.response else 'unknown'}")) from exc except requests.exceptions.RequestException as exc: raise GeoToolError(build_geocode_failure_message(place, query, f"在线地理编码请求失败:{exc}")) from exc cache[cache_key] = point.__dict__ results.append(point) save_json_cache(GEO_CACHE_PATH, cache) return results def build_route_matrix(points: list[GeoPoint], timeout: int = 15) -> RouteMatrix: if len(points) < 2: raise GeoToolError("至少需要起点和 1 个目的地。") coords = ";".join(f"{point.lon:.6f},{point.lat:.6f}" for point in points) url = f"{OSRM_TABLE_URL}/{coords}" params = {"annotations": "duration,distance"} try: response = requests.get(url, params=params, timeout=timeout) response.raise_for_status() payload = response.json() durations = payload.get("durations") distances = payload.get("distances") if not matrix_is_complete(durations) or not matrix_is_complete(distances): raise GeoToolError("OSRM 返回的矩阵不完整。") return RouteMatrix( points=points, durations=durations, distances=distances, source="OSRM public demo server", ) except Exception: durations, distances = approximate_city_matrix(points) return RouteMatrix( points=points, durations=durations, distances=distances, source="haversine fallback, speed=35km/h, road factor=1.30", ) def geocode_with_nominatim(place: str, query: str, timeout: int) -> GeoPoint: headers = { "User-Agent": "routeopt-agent-homework/1.0 (public classroom demo)", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.6", } params = { "q": query, "format": "jsonv2", "limit": 1, "addressdetails": 1, } response = requests.get(NOMINATIM_URL, params=params, headers=headers, timeout=timeout) response.raise_for_status() items = response.json() if not items: raise GeoToolError(build_geocode_failure_message(place, query, "在线地理编码没有找到匹配地点")) item = items[0] return GeoPoint( name=place, query=query, lat=float(item["lat"]), lon=float(item["lon"]), display_name=item.get("display_name", query), source="OpenStreetMap Nominatim", ) def lookup_demo_point(place: str) -> tuple[float, float, str] | None: normalized = place.strip().lower() for key, value in DEMO_POINTS.items(): key_norm = key.lower() if normalized == key_norm or normalized in key_norm or key_norm in normalized: return value return None def parse_manual_point(place: str) -> GeoPoint | None: patterns = [ r"^(?P.+?)[@|]\s*(?P-?\d+(?:\.\d+)?)\s*[,,]\s*(?P-?\d+(?:\.\d+)?)$", r"^(?P.+?)[((]\s*(?P-?\d+(?:\.\d+)?)\s*[,,]\s*(?P-?\d+(?:\.\d+)?)\s*[))]$", ] for pattern in patterns: match = re.match(pattern, place.strip()) if not match: continue name = match.group("name").strip() lat = float(match.group("lat")) lon = float(match.group("lon")) if not name: raise GeoToolError("手动坐标格式缺少地点名称。正确格式示例:未知景点@39.90,116.40") if not (-90 <= lat <= 90 and -180 <= lon <= 180): raise GeoToolError(f"手动坐标超出范围:{place}。纬度应在 -90 到 90,经度应在 -180 到 180。") return GeoPoint( name=name, query=place, lat=lat, lon=lon, display_name=f"{name}(手动坐标)", source="manual-coordinate", ) return None def build_geocode_failure_message(place: str, query: str, reason: str) -> str: return ( f"地点解析失败:`{place}`。\n\n" f"失败原因:{reason}。\n\n" f"本次查询语句:`{query}`。\n\n" f"当前内置演示坐标覆盖城市:{builtin_city_summary()}。如果你输入的是其他城市或较冷门地点," "系统会尝试在线地理编码;当在线服务超时、限流或找不到地点时,就需要你把地名写得更具体," "例如加上城市、区县、校区/景区全称。\n\n" "也可以直接使用手动坐标格式绕过在线地理编码:`地点名@纬度,经度`,例如 `某景点@39.9042,116.4074`。" ) def builtin_city_summary() -> str: return "、".join(BUILTIN_DEMO_CITIES) def builtin_place_examples(limit: int = 12) -> str: names = list(DEMO_POINTS.keys())[:limit] return "、".join(names) def approximate_city_matrix(points: list[GeoPoint]) -> tuple[list[list[float]], list[list[float]]]: distances: list[list[float]] = [] durations: list[list[float]] = [] road_factor = 1.30 speed_mps = 35_000 / 3600 for origin in points: distance_row: list[float] = [] duration_row: list[float] = [] for dest in points: if origin is dest: distance = 0.0 else: distance = haversine_meters(origin.lat, origin.lon, dest.lat, dest.lon) * road_factor distance_row.append(distance) duration_row.append(distance / speed_mps if speed_mps else 0.0) distances.append(distance_row) durations.append(duration_row) return durations, distances def haversine_meters(lat1: float, lon1: float, lat2: float, lon2: float) -> float: radius = 6_371_000 phi1 = math.radians(lat1) phi2 = math.radians(lat2) d_phi = math.radians(lat2 - lat1) d_lambda = math.radians(lon2 - lon1) a = ( math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2 ) return 2 * radius * math.atan2(math.sqrt(a), math.sqrt(1 - a)) def matrix_is_complete(matrix: Any) -> bool: if not isinstance(matrix, list) or not matrix: return False size = len(matrix) return all(isinstance(row, list) and len(row) == size and all(value is not None for value in row) for row in matrix) def format_osm_link(point: GeoPoint) -> str: return f"https://www.openstreetmap.org/?mlat={point.lat:.6f}&mlon={point.lon:.6f}#map=14/{point.lat:.6f}/{point.lon:.6f}" def make_osrm_route_link(points: list[GeoPoint], route_indices: list[int]) -> str: coords = ";".join(f"{points[idx].lon:.6f},{points[idx].lat:.6f}" for idx in route_indices) return f"https://router.project-osrm.org/route/v1/driving/{quote(coords, safe=';,')}" def sleep_for_nominatim(last_live_query_at: float) -> None: elapsed = time.time() - last_live_query_at if last_live_query_at and elapsed < 1.05: time.sleep(1.05 - elapsed) def load_json_cache(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return {} def save_json_cache(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") def normalize_key(place: str, city_hint: str) -> str: return f"{place.strip().lower()}|{city_hint.strip().lower()}"