feat(meituan-cps): 经纬度→城市离线反查 + rec/销量最高按城市过滤

- app/utils/geo.py: reverse_geocoder 单例(mode=1 单进程 KDTree),经纬度→最近聚居点
- app/utils/meituan_city.py: 坐标→美团 city_id(省份/城市名桥接 + 多级兜底 + lru_cache 量化)
- feed(rec) / top-sales: 按解析出的 city_id 过滤离线库;城市解析不出 / 老客户端不带坐标 → degraded
- top-sales 与 rec 一致置空库内距离(相对城市默认点,对用户无意义)
- main.py 启动预热 KDTree;pyproject 加 reverse_geocoder 依赖 + 分发 city_dict.txt
- 新增 geo / meituan_city 测试(56 例);scripts/load_meituan_coupon_tsv.py 灌样本到本地 SQLite
- .gitignore 忽略样本 TSV 与 .claude 本地设置

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
guke
2026-07-04 16:00:06 +08:00
parent a563c1ca4b
commit dc63632e77
12 changed files with 1312 additions and 5 deletions
+65
View File
@@ -0,0 +1,65 @@
"""通过经纬度反查城市(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", "")),
}