"""通过经纬度反查城市(reverse_geocoder 离线库,零网络调用)。 reverse_geocoder 内置 ~2.5M 条全球城市/聚居点的经纬度→地名映射表, 构建一次 KDTree(~几十MB 内存)后,查询为纯内存搜索,不作任何外部网络调用。 ⚠️ 必须持有单例、且用 mode=1(单进程): - reverse_geocoder 的模块级 rg.search()/rg.get() **每次调用都会 new 一个 RGeocoder**, 即每次都重新解析 ~2.5M 行 CSV + 重建 KDTree(数秒/次)。绝不能在服务端按请求调用。 - 默认 mode=2 用 multiprocessing 按 CPU 数 spawn 子进程做并行查询;在服务端 / Windows spawn 下会重复 import 主模块(无 __main__ guard 时直接报错),既慢又危险。 故本模块持有一个 mode=1 的 RGeocoder 单例,建一次树、复用;查询走单进程内存搜索。 ⚠️ 精度说明:gazetteer 里的中国数据粒度不一致——直辖市/省会通常直接命中城市名, 但部分城市会命中到区/街道级(如天津→Erwangzhuang、西安→Zhangjiabao), 此时 admin1(省级行政区)可作为回退。业务侧建议优先用 admin1 做城市级判定。 """ from __future__ import annotations from typing import Any import reverse_geocoder as rg # type: ignore[import-untyped] # mode=1 单进程 KDTree 的单例;None 表示尚未构建(见 ensure_loaded)。 _geocoder: rg.RGeocoder | None = None def ensure_loaded() -> None: """构建(或复用)RGeocoder 单例(幂等)。 首次调用解析 ~2.5M 行 CSV + 构建 KDTree(~秒级、数十 MB)。生产应在 main.py 的 lifespan 启动阶段主动调用一次,把这份一次性成本摊到启动,避免砸在第一个 /feed?tab=rec / /top-sales 请求上。mode=1 = 单进程,不 spawn 子进程。 """ global _geocoder if _geocoder is None: _geocoder = rg.RGeocoder(mode=1, verbose=False) def get_city(latitude: float, longitude: float) -> dict[str, str]: """根据经纬度反查最近聚居点。 返回 dict: - name: 最近聚居点名称(英文),如 "Beijing" / "Fengsheng"; 中国境内可能是区/街道级;海洋/无人区返 "" - admin1: 省级行政区(英文),如 "Beijing" / "Hubei" / "Chongqing Shi"; 直辖市 admin1 即为城市名 - country: ISO 3166-1 alpha-2,如 "CN" - latitude: 匹配到的参考点纬度(字符串) - longitude: 匹配到的参考点经度(字符串) 未匹配到(海洋/远洋)时返回空字符串字段。 """ ensure_loaded() assert _geocoder is not None # ensure_loaded 保证已构建 results: list[dict[str, Any]] = _geocoder.query([(latitude, longitude)]) if not results: return {"name": "", "admin1": "", "country": "", "latitude": "", "longitude": ""} r = results[0] return { "name": str(r.get("name", "")), "admin1": str(r.get("admin1", "")), "country": str(r.get("cc", "")), "latitude": str(r.get("lat", "")), "longitude": str(r.get("lon", "")), }