归属判定升级:DataV 官方行政区划表 + 同名县消歧
用阿里 DataV GeoAtlas(免密钥)替换 OSM Overpass 离线县表: - 民政部 adcode 体系,县级前 4 位即所属地级码,2832 县 100% 可定位地级 (OSM 表只有 1182/2362,且 admin_level 层级不可靠) - 同名县(全国 30 组)保留全部候选,运行时按坐标消歧, 修掉「柳州的城中区被归到西宁市」 归属判定改为「按名字精确查官方表」优先于「按中心点猜最近的县」: 窄长县按中心点会跨省错判(环江喀斯特实际在环江县,按中心点判到荔波县) 查表带距离校验,精确全称命中放宽到 150km(狭长县中心离边界可达 85km+) 修掉三个真实缺陷: - Overpass 兜底把地级塞进 osmCounty、县名塞进 osmCity, 与 attachPlaces「先找县级」的顺序相反,导致兜底路径永远丢下层县名 - Overpass 请求无硬超时,公共实例「连着不返回」能把一次分析拖成十几分钟; 补三层限(单次超时 / 阶段总时限 / is_in 反查次数上限),13 分钟 → 48 秒 - Photon 的 osm.place.city 类目混着景点 POI,kind==='城市' 时无条件把名字 当地名,导致「社更穿洞北门」「柳州之门」等景点自己成一组; 改为必须同时是行政区名且能在官方表查到 新增离线回归测试(都不打外部网络,Photon/Overpass 挂掉也能回归): - test-admin-lookup:同名县消歧、已知边界点、狭长县远端点(19 项) - test-overpass-fallback:本地假 Overpass,验兜底路径、简称归一、200+HTML 错误页 - test-grouping-regression:本地假 Photon,20 候选完整分组 - verify-ui-full:端到端 UI 验收(真实无头 Chrome 读真实 DOM)
This commit is contained in:
@@ -35,7 +35,7 @@ node server.js # 或 npm start
|
|||||||
| 步骤 | 用户操作 | 系统行为 |
|
| 步骤 | 用户操作 | 系统行为 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| ① 出行条件 | 输入地名联想 / 浏览器定位、选日期区间、单程时长上限、搜索半径、是否排除同城、交通与兴趣偏好 | 地理编码 → 检索周边候选 → 批量取天气与 AQI → 公路矩阵 + 铁路可达性 → 评分排序 |
|
| ① 出行条件 | 输入地名联想 / 浏览器定位、选日期区间、单程时长上限、搜索半径、是否排除同城、交通与兴趣偏好 | 地理编码 → 检索周边候选 → 批量取天气与 AQI → 公路矩阵 + 铁路可达性 → 评分排序 |
|
||||||
| ② 备选清单 | 查看方位罗盘、评分环、逐日天气、推荐理由、数据来源说明;勾选目的地 | 默认预选 Top 3 并给出提示;支持按评分/天气/距离/单程时长排序;可「只看小众」、可「对比已选」并排看关键指标 |
|
| ② 备选清单 | 查看方位罗盘、评分环、逐日天气、推荐理由、数据来源说明;按地名分组浏览、搜索目的地;勾选目的地 | 默认预选 Top 3 并给出提示;支持按评分/天气/距离/单程时长排序;按「地名 → 景点」两级分组;可搜索、可「只看小众」、可「对比已选」并排看关键指标 |
|
||||||
| ③ 行程规划 | 填写具体需求(同行人、预算、体力、禁忌等),点击生成;生成后可继续提要求迭代 | 清单数据 + 需求 + 本站预设提示词 → OpenAI 兼容接口流式输出 Markdown 方案 |
|
| ③ 行程规划 | 填写具体需求(同行人、预算、体力、禁忌等),点击生成;生成后可继续提要求迭代 | 清单数据 + 需求 + 本站预设提示词 → OpenAI 兼容接口流式输出 Markdown 方案 |
|
||||||
|
|
||||||
页面右上角「设置」分两块:**AI 接口**(地址、密钥、模型,含常用供应商预设、预设提示词查看/复制、强制演示模式)与 **数据源增强**(高德 Web 服务 Key)。两者都只存在浏览器 `localStorage`,随请求传给本地服务代理。
|
页面右上角「设置」分两块:**AI 接口**(地址、密钥、模型,含常用供应商预设、预设提示词查看/复制、强制演示模式)与 **数据源增强**(高德 Web 服务 Key)。两者都只存在浏览器 `localStorage`,随请求传给本地服务代理。
|
||||||
@@ -109,6 +109,100 @@ node server.js # 或 npm start
|
|||||||
- Photon 分类检索用 `bbox + 分类` 作为稳定缓存键(公共实例限流,重复分析必须复用缓存)。
|
- Photon 分类检索用 `bbox + 分类` 作为稳定缓存键(公共实例限流,重复分析必须复用缓存)。
|
||||||
- 候选上限 28 个,按「类型轮转取样」抽取,保证小众点位不会被热门点位挤掉。
|
- 候选上限 28 个,按「类型轮转取样」抽取,保证小众点位不会被热门点位挤掉。
|
||||||
|
|
||||||
|
### 归属地名与分组
|
||||||
|
|
||||||
|
第二步把候选按「**地名 → 景点**」两级展示,所以每个候选都要先解析出它属于哪个地名。
|
||||||
|
|
||||||
|
| 来源 | 地名来源 | 层级 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 内置城市库 | 名字本身就是地名(柳州市) | 地级 |
|
||||||
|
| 高德 POI | `cityname`(地级市)+ `adname`(区县) | 地级 + 区县 |
|
||||||
|
| 内置知名 / 小众库 | 离线归属表 `lib/places-admin.js` | 地级 + 区县 |
|
||||||
|
| Photon / Overpass (OSM) | **按 OSM 自带的县名字段查官方表** `lib/places-admin-dv.js` | 地级 + 区县 |
|
||||||
|
| 以上都拿不到 | 按坐标找最近的县 → 推出地级(官方县表,≤60km) | 地级 + 区县 |
|
||||||
|
|
||||||
|
铁律:
|
||||||
|
|
||||||
|
- **组名必须落在地级**(市 / 州 / 盟 / 地区)。用街道名或村名当组名等于没分组——同一个县下面的几十个点会被拆成几十组。
|
||||||
|
- **Photon 的 `osm.place.city` 类目里混着景点 POI**(牌坊、寺庙、老街),它们的名字也带「市 / 州」后缀。不能因为 `kind === '城市'` 就把名字当地名——会出现「社更穿洞北门」自己成一组这种荒唐结果。必须同时满足「名字是行政区名」且「能在官方地级表里查到」才当地名节点,否则走正常归属流程。
|
||||||
|
- **OSM 中国的字段层级与直觉相反**:`county` 是地级市/州,`city` 是县/区,`district` 是街道,`locality` 是村。
|
||||||
|
- **优先按名字精确查官方表,别靠「按坐标猜最近的县」**。窄长县按中心点距离会判到隔壁省去:「环江喀斯特世界自然遗产」实际在环江县(河池市),按中心点却会判到 45km 外的荔波县(黔南州)——差一级省。而 OSM 的 `addr:county` 在多数条目上直接写了标准县名(`环江毛南族自治县`),按名字查是准的。
|
||||||
|
- 查表**必须带距离校验**:同名县离候选点太远(>80km)就判为不可信,退回按坐标近邻。
|
||||||
|
- 查表顺序:先在 `county` / `city` / `district` 三个字段里找**县级**(拿到下层地名),都找不到再退地级。OSM 常常把地级填进 `county`、县名填进 `city`(「黔南州」+「荔波县」),只看第一个字段就定案会丢掉下层地名。
|
||||||
|
- **不要信 OSM 自己填的归属字段的层级**。Photon 的 `city` 字段会给出「乐依村」「陈家甫」这种村名当区县——所以查到的东西必须经 `isDistrictName()`(县/区/旗/盟/州/市)过滤,村名一律丢弃。
|
||||||
|
- **OSM 的 `admin_level` 在中国是 L4=省 / L5=地级市 / L6=县**,不是 L4/L6/L8。查 `admin_level~"^[468]$"` 会正好跳过地级市那层。
|
||||||
|
- 分组键会剥掉行政后缀(「柳州」与「柳州市」归一组),民族自治州要连民族名一起去掉(「黔南布依族苗族自治州」→「黔南州」)。
|
||||||
|
- 高德 `regeo` 的**批量查询不可用**(`batch=true` + `locations`,无论分隔符与参数顺序都返回 `INVALID_PARAMS`),必须逐点反查;并发 3 会触发限流,失败的候选会退回街道名当组名,因此用**串行 + 60ms 间隔**。
|
||||||
|
|
||||||
|
#### 离线行政区划表(阿里 DataV GeoAtlas)
|
||||||
|
|
||||||
|
| 表 | 条数 | 作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `lib/places-admin.js` | 323 | 内置知名 / 小众地点的归属(地级 + 区县) |
|
||||||
|
| `lib/places-admin-dv.js` | 2832 县 / 343 地级 | **官方行政区划表**:按名字查县/地级,运行时归属判定的主力 |
|
||||||
|
|
||||||
|
数据来自[阿里 DataV GeoAtlas](https://datav.aliyun.com/portal/school/atlas/area_selector)(免密钥、免鉴权),用的是国家民政部 **adcode** 体系:省级 2 位 / 地级 4 位 / 县级 6 位,**县级前 4 位即所属地级码**(451226 环江县 → 4512 河池市)——归属关系是**编码里写死的**,不需要推断。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 拉原始数据(三级下钻:省 → 地级市 → 县),输出到 C:/tmp/datav-admin.json
|
||||||
|
node tools/fetch-datav-admin.cjs
|
||||||
|
# 2) 生成运行时表
|
||||||
|
node tools/build-admin-dv.cjs C:/tmp/datav-admin.json
|
||||||
|
```
|
||||||
|
|
||||||
|
> 早期版本用的是 Overpass 拉的 OSM 县表(`lib/places-county.js` / `lib/places-prefecture.js`),**已废弃**:OSM 中国的 `admin_level` 层级不可靠、编码只有约 50% 覆盖(1182/2362 个县查不到所属地级),且公共 Overpass 实例限流极狠(大查询后连 1KB 的小查询都 502/504)。DataV 版 2832 个县**全部**能定位到所属地级。
|
||||||
|
|
||||||
|
几个必须记住的坑:
|
||||||
|
|
||||||
|
- **同名县要保留全部候选**。全国有 **30 组**同名县(「城中区」柳州/西宁都有、「鼓楼区」南京/徐州/福州/开封都有、「铁西区」沈阳/鞍山/四平都有、「市中区」济南/枣庄/内江/乐山都有)。用县名做单一字典键会互相覆盖——柳州的「城中区」会被归到西宁市。所以表里同名县的值是**数组**,运行时按坐标距离选最近的那个。
|
||||||
|
- **省直辖县级单位不是地级市**。DataV 把海南的东方市/昌江黎族自治县、新疆兵团的石河子市、湖北的仙桃市等标成 `level=city`,靠 **adcode 是否以 `00` 结尾**区分(真地级市是 xx00,省直辖县级是 9xxx 段)。不按这个分,归属会整整差一级。
|
||||||
|
- **剥民族名要用显式清单**,不能用 `[一-龥]{1,3}族` 这种贪婪正则——「环江毛南族自治县」会被吃成「环自治县」。而且顺序很重要:先剥「自治县/州」尾巴,民族名块才会露在末尾。
|
||||||
|
- **别名表要县/地分开**(`COUNTY_ALIAS` → 县全称,`PREF_ALIAS` → 地级简称)。混在一张表里会因为「同名县/市」互相覆盖而查错。
|
||||||
|
|
||||||
|
#### 兜底路径:Photon 全挂时走 Overpass
|
||||||
|
|
||||||
|
| 步骤 | 实现 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1. 取候选 | `overpassQuery()` | 按 `OVERPASS_TAGS` 逐类查 OSM 原始标签 |
|
||||||
|
| 2. 取行政区 | `overpassAdminAreas()` | `out center tags` 取 bbox 内 L5/L6 行政区的**中心点** |
|
||||||
|
| 3. 判归属 | `areaByPoint()` | 按中心点做近邻判定 |
|
||||||
|
| 4. 精确反查 | `overpassAdminAtPoint()` | 近邻判定拿不到时逐点用 `is_in` 反查 |
|
||||||
|
| 5. 归一 | `lookupAdminByName()` | OSM 的简称/全称统一过官方表,同名县按距离消歧 |
|
||||||
|
|
||||||
|
关键事实:
|
||||||
|
|
||||||
|
- **relation 级 geometry 在 Overpass 里拿不到**。`out tags geom` 和 `out geom` 都不返回 relation 级的 `geometry` 数组(几何在 `members` 里)。所以不要走「拉多边形 + 本地点面判定」这条路。
|
||||||
|
- **`is_in` 反查一次就能拿到完整层级**:`[out:json];is_in(lat,lon)->.a;.a out tags;` 返回 L4 省 / L5 地级 / L6 县(实测 25.1,107.98 → 环江县 / 河池市 / 广西)。注意 `rel(pivot.a)` 拿不到点→区映射,要直接输出 area 集合。
|
||||||
|
- **公共实例必须多镜像轮换**,且要判定响应体是否真是 JSON:实例可能返回 **200 + HTML 错误页**(如 `duplicate_query`),只看 `res.status` 会把错误页当成功。判据是响应体是否以 `{` 开头。
|
||||||
|
- **必须有硬超时和总时限预算**。公共实例经常「连着不返回」(不报错也不结束),没有 `AbortSignal.timeout` 的话一次兜底能把分析流程拖成十几分钟(实测踩过)。三处都要限:
|
||||||
|
- 单次请求超时(`SEARCH.overpassTimeoutMs`,默认 20s);
|
||||||
|
- 单个阶段的总时限(镜像轮询不超过 2 倍超时);
|
||||||
|
- 逐点 `is_in` 反查的次数上限(`SEARCH.overpassIsInMax`,默认 8)——这是最慢的一环,超限后改用 bbox 行政区中心点近邻判定。
|
||||||
|
- 超时后**候选照常入池**(归属还有离线官方表兜底),不能因为反查超时就丢掉整个候选。
|
||||||
|
- Overpass 的兴趣点大多**没有 `addr:*` 标签**,直接拿归属必然得到空组名——必须靠 `is_in` 反查。
|
||||||
|
|
||||||
|
#### 离线回归测试
|
||||||
|
|
||||||
|
归属逻辑不能只靠真实网络验证——Photon / Overpass 随时会挂,挂了就没法回归。仓库里有三个**不发外部请求**的离线测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/test-admin-lookup.cjs # 归属判定单测:同名县消歧、已知边界点、狭长县远端点
|
||||||
|
node tools/test-overpass-fallback.cjs # 起本地假 Overpass:验兜底路径、简称归一、200+HTML 错误页
|
||||||
|
node tools/test-grouping-regression.cjs # 起本地假 Photon:20 个候选的完整分组回归
|
||||||
|
```
|
||||||
|
|
||||||
|
另有端到端 UI 验收(起假 Photon + 真实无头 Chrome,读真实 DOM):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/verify-ui-full.cjs # 分组 / undefined / 空组名 / 搜索 / 折叠 + 截图
|
||||||
|
```
|
||||||
|
|
||||||
|
写这类测试时注意:**`cfg.SEARCH.maxCandidates` 缺省会静默返回 0 候选**(`selected.length < undefined` 恒假);**第二轮跑要清 `require.cache` 或换 origin**,否则命中第一轮缓存。
|
||||||
|
|
||||||
|
拉取时的坑:一次拉全国必然 502(5 个镜像全挂),必须按 10°×15° 分块;用 `out center` 而不是 `out geom`(几何版体积大十几倍,更容易超时)。境外行政区会混进同一层级数据,靠**有无 `division_code` 或 `name` 本身是中文**来剔除。
|
||||||
|
|
||||||
|
县级表第三项是**所属地级名**,由 GB/T 2260 编码前缀推出(县级码前 4 位即地级码:荔波县 `522722` → `5223` 黔南州)。这比「按距离找最近地级」精确得多——桂黔边境荔波县与河池市相距不到 40km,按中心点距离会分错。
|
||||||
|
|
||||||
### 评分口径
|
### 评分口径
|
||||||
|
|
||||||
总分 0~100,由六项加权相加:天气舒适度 `0~40`、时间适配 `0~26`、兴趣匹配 `0~14`、距离 `0~7`、目的地辨识度 `0~8`、空气质量 `0~6`。整段行程都是坏天气时会触发硬门槛,不允许给出「强烈推荐」。每张卡片都带 `breakdown`、`reasons`、`warnings`,可解释。
|
总分 0~100,由六项加权相加:天气舒适度 `0~40`、时间适配 `0~26`、兴趣匹配 `0~14`、距离 `0~7`、目的地辨识度 `0~8`、空气质量 `0~6`。整段行程都是坏天气时会触发硬门槛,不允许给出「强烈推荐」。每张卡片都带 `breakdown`、`reasons`、`warnings`,可解释。
|
||||||
@@ -138,6 +232,9 @@ node server.js # 或 npm start
|
|||||||
| BigDataCloud / Photon Reverse | 浏览器定位后的反向地理编码 | 免 | 提示手动输入地名 |
|
| BigDataCloud / Photon Reverse | 浏览器定位后的反向地理编码 | 免 | 提示手动输入地名 |
|
||||||
| 内置 `lib/places-cn.js` | 全国知名景区 + 主要城市坐标库 | — | 始终参与,保证任何城市都有可用清单 |
|
| 内置 `lib/places-cn.js` | 全国知名景区 + 主要城市坐标库 | — | 始终参与,保证任何城市都有可用清单 |
|
||||||
| 内置 `lib/places-niche.js` | 206 条小众地点:古村、遗址、湿地、地质景观、洞穴、小馆、徒步线 | — | 始终参与,并保证约 30% 的候选名额 |
|
| 内置 `lib/places-niche.js` | 206 条小众地点:古村、遗址、湿地、地质景观、洞穴、小馆、徒步线 | — | 始终参与,并保证约 30% 的候选名额 |
|
||||||
|
| 内置 `lib/places-admin.js` | 内置地点的归属地名(323 条,离线生成) | — | 始终参与;缺失时按坐标回退到最近地级市 |
|
||||||
|
| 内置 `lib/places-admin-dv.js` | 官方行政区划表:2832 县 / 343 地级(DataV GeoAtlas,离线生成) | — | 始终参与;同名县按坐标消歧,查不到时退到按坐标近邻 |
|
||||||
|
| Overpass (OpenStreetMap) | Photon 全挂时的候选兜底(免密钥),多镜像轮换 | 免 | 跳过并注明「实时检索不可用」;实例返回 200 但内容是 HTML 错误页时同样判为失败 |
|
||||||
| 高德 Web 服务 `geocode/geo` / `place/text` / `place/around`(含 `types=100000` 住宿)/ `direction/driving` / `staticmap`(路线图真实底图) | 中文地名检索、实时周边 POI、周边住宿参考、国内驾车路径、地图图层 | `AMAP_KEY` 或页面「设置 → 数据源增强」 | 静默跳过并退回 Open-Meteo / OSRM / 内置库 / 方位示意图;Key 无效会在页面顶部或设置里直接报错 |
|
| 高德 Web 服务 `geocode/geo` / `place/text` / `place/around`(含 `types=100000` 住宿)/ `direction/driving` / `staticmap`(路线图真实底图) | 中文地名检索、实时周边 POI、周边住宿参考、国内驾车路径、地图图层 | `AMAP_KEY` 或页面「设置 → 数据源增强」 | 静默跳过并退回 Open-Meteo / OSRM / 内置库 / 方位示意图;Key 无效会在页面顶部或设置里直接报错 |
|
||||||
| RollingGo `rgh` CLI(本机安装) | 酒店实时参考价、星级、距离、可预订链接 | `npm i -g @rollinggo/hotel` + `rgh login` | 跳过,退回高德 POI;只调用只读子命令 |
|
| RollingGo `rgh` CLI(本机安装) | 酒店实时参考价、星级、距离、可预订链接 | `npm i -g @rollinggo/hotel` + `rgh login` | 跳过,退回高德 POI;只调用只读子命令 |
|
||||||
| 铁路接口 | 真实高铁/城际耗时与票价 | `RAIL_API_URL` | 降级为里程-速度估算并标注 |
|
| 铁路接口 | 真实高铁/城际耗时与票价 | `RAIL_API_URL` | 降级为里程-速度估算并标注 |
|
||||||
|
|||||||
@@ -23,7 +23,19 @@ module.exports = {
|
|||||||
forecast: 'https://api.open-meteo.com/v1/forecast',
|
forecast: 'https://api.open-meteo.com/v1/forecast',
|
||||||
archive: 'https://archive-api.open-meteo.com/v1/archive',
|
archive: 'https://archive-api.open-meteo.com/v1/archive',
|
||||||
airQuality: 'https://air-quality-api.open-meteo.com/v1/air-quality',
|
airQuality: 'https://air-quality-api.open-meteo.com/v1/air-quality',
|
||||||
photon: 'https://photon.komoot.io/api',
|
photon: env.PHOTON_ENDPOINT || 'https://photon.komoot.io/api',
|
||||||
|
// Overpass 兜底:Photon 全挂时用(免密钥,但单次查询要 20~120 秒)。
|
||||||
|
// 公共实例会限流/限容量,必须多镜像轮换;osm.ch 覆盖较全但偶尔返回
|
||||||
|
// 「duplicate_query」错误页,靠响应体是否以 '{' 开头来判定真伪。
|
||||||
|
overpass: env.OVERPASS_ENDPOINTS
|
||||||
|
? env.OVERPASS_ENDPOINTS.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [
|
||||||
|
'https://overpass-api.de/api/interpreter',
|
||||||
|
'https://overpass.kumi.systems/api/interpreter',
|
||||||
|
'https://overpass.private.coffee/api/interpreter',
|
||||||
|
'https://overpass.osm.ch/api/interpreter',
|
||||||
|
'https://overpass.osm.jp/api/interpreter',
|
||||||
|
],
|
||||||
reverse: 'https://api.bigdatacloud.net/data/reverse-geocode-client',
|
reverse: 'https://api.bigdatacloud.net/data/reverse-geocode-client',
|
||||||
osrmTable: 'https://router.project-osrm.org/table/v1/driving',
|
osrmTable: 'https://router.project-osrm.org/table/v1/driving',
|
||||||
amapDriving: 'https://restapi.amap.com/v3/direction/driving',
|
amapDriving: 'https://restapi.amap.com/v3/direction/driving',
|
||||||
@@ -74,6 +86,12 @@ module.exports = {
|
|||||||
requestTimeoutMs: 15000, // 天气/地理编码等主数据源
|
requestTimeoutMs: 15000, // 天气/地理编码等主数据源
|
||||||
osrmTimeoutMs: 12000,
|
osrmTimeoutMs: 12000,
|
||||||
amapAroundRadius: 50000, // 高德 around 检索上限 50km
|
amapAroundRadius: 50000, // 高德 around 检索上限 50km
|
||||||
|
// Overpass 兜底的超时。公共实例经常「连着不返回」(不报错也不结束),
|
||||||
|
// 没有硬超时的话一次兜底能把整个分析流程拖成十几分钟。
|
||||||
|
overpassTimeoutMs: Number(env.OVERPASS_TIMEOUT_MS || 20000),
|
||||||
|
// 逐点 is_in 反查的上限:最多反查这么多个点,
|
||||||
|
// 超出部分改用 bbox 行政区中心点近邻判定(近似但快得多)。
|
||||||
|
overpassIsInMax: Number(env.OVERPASS_ISIN_MAX || 8),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ------------------------------------------------- OpenAI 兼容接口(预留)
|
// ------------------------------------------------- OpenAI 兼容接口(预留)
|
||||||
|
|||||||
@@ -279,8 +279,10 @@ async function analyze(input, cfg, progress = () => {}) {
|
|||||||
const dataNotes = [
|
const dataNotes = [
|
||||||
`周边目的地:Photon / OpenStreetMap(成功 ${stats.categoryHits}/${stats.requested} 类)` +
|
`周边目的地:Photon / OpenStreetMap(成功 ${stats.categoryHits}/${stats.requested} 类)` +
|
||||||
`${stats.amapCount ? ` + 高德周边 ${stats.amapCount} 条` : ''}` +
|
`${stats.amapCount ? ` + 高德周边 ${stats.amapCount} 条` : ''}` +
|
||||||
|
`${stats.overpassCount ? ` + Overpass 兜底 ${stats.overpassCount} 条` : ''}` +
|
||||||
` + 内置知名景区 ${stats.curatedCount} 个 + 内置城市 ${stats.cityCount} 个 + 内置小众地点 ${stats.nicheCount} 个` +
|
` + 内置知名景区 ${stats.curatedCount} 个 + 内置城市 ${stats.cityCount} 个 + 内置小众地点 ${stats.nicheCount} 个` +
|
||||||
`${stats.fallbackOnly ? ';⚠️ 实时 POI 服务不可用,本次清单完全来自内置地名库(坐标为近似值)' : ''}` +
|
`${stats.fallbackOnly ? ';⚠️ 实时 POI 服务不可用,本次清单完全来自内置地名库(坐标为近似值)' : ''}` +
|
||||||
|
`${stats.overpassError ? `;Overpass 兜底也失败(${stats.overpassError})` : ''}` +
|
||||||
`${c.excludeLocal && stats.localExcluded ? `;已排除出发地所在城区的同城点位 ${stats.localExcluded} 个(同市下辖的其它县保留为周边目的地)` : ''}`,
|
`${c.excludeLocal && stats.localExcluded ? `;已排除出发地所在城区的同城点位 ${stats.localExcluded} 个(同市下辖的其它县保留为周边目的地)` : ''}`,
|
||||||
`天气:${weatherRes.note}`,
|
`天气:${weatherRes.note}`,
|
||||||
`空气质量:${aqiRes.note}`,
|
`空气质量:${aqiRes.note}`,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+700
-2
@@ -12,6 +12,8 @@
|
|||||||
const U = require('./util');
|
const U = require('./util');
|
||||||
const C = require('./cache');
|
const C = require('./cache');
|
||||||
const { JUNK_NAME, POOR_NAME, LOW_VALUE_NAME, CURATED, CN_CITIES, NICHE_SPOTS, wmo } = require('./data');
|
const { JUNK_NAME, POOR_NAME, LOW_VALUE_NAME, CURATED, CN_CITIES, NICHE_SPOTS, wmo } = require('./data');
|
||||||
|
const ADMIN = require('./places-admin');
|
||||||
|
const DV = require('./places-admin-dv');
|
||||||
|
|
||||||
// 数据源层缓存 TTL(秒级语义):
|
// 数据源层缓存 TTL(秒级语义):
|
||||||
// 地理编码 7 天(行政区划不会变)/高德 POI 6 小时(商家会变)
|
// 地理编码 7 天(行政区划不会变)/高德 POI 6 小时(商家会变)
|
||||||
@@ -167,6 +169,9 @@ function photonCandidate(feature, cat, origin) {
|
|||||||
if (JUNK_NAME.test(name) || POOR_NAME.test(name) || LOW_VALUE_NAME.test(name)) return null;
|
if (JUNK_NAME.test(name) || POOR_NAME.test(name) || LOW_VALUE_NAME.test(name)) return null;
|
||||||
if (/^\d+$/.test(name)) return null;
|
if (/^\d+$/.test(name)) return null;
|
||||||
if (name.length < 2 || name.length > 30) return null;
|
if (name.length < 2 || name.length > 30) return null;
|
||||||
|
// 纯外文的条目(如「Rice Terraces」)对中文用户没有辨识度,
|
||||||
|
// 而且 OSM 上外国景点会混进来占名额。名称里至少要有一个汉字。
|
||||||
|
if (!/[\u4e00-\u9fa5]/.test(name)) return null;
|
||||||
const distanceKm = U.haversine(origin.lat, origin.lon, lat, lon);
|
const distanceKm = U.haversine(origin.lat, origin.lon, lat, lon);
|
||||||
return {
|
return {
|
||||||
id: `osm-${p.osm_type || 'N'}-${p.osm_id || Math.round(lat * 1e5)}`,
|
id: `osm-${p.osm_type || 'N'}-${p.osm_id || Math.round(lat * 1e5)}`,
|
||||||
@@ -175,7 +180,12 @@ function photonCandidate(feature, cat, origin) {
|
|||||||
tags: [...cat.tags],
|
tags: [...cat.tags],
|
||||||
lat: U.round(lat, 5),
|
lat: U.round(lat, 5),
|
||||||
lon: U.round(lon, 5),
|
lon: U.round(lon, 5),
|
||||||
region: [p.city, p.district, p.locality].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).slice(0, 2).join(' · '),
|
// OSM 中国的层级命名与直觉相反:county 是地级市/州,city 是县/区,
|
||||||
|
// district 是街道,locality 是村。分组必须用 county,取 district 会分出「玉屏街道」这种组。
|
||||||
|
osmCounty: String(p.county || '').trim(),
|
||||||
|
osmCity: String(p.city || '').trim(),
|
||||||
|
osmDistrict: String(p.district || '').trim(),
|
||||||
|
region: [p.county, p.city, p.district].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).slice(0, 2).join(' · '),
|
||||||
distanceKm,
|
distanceKm,
|
||||||
bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon),
|
bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon),
|
||||||
source: 'OpenStreetMap / Photon',
|
source: 'OpenStreetMap / Photon',
|
||||||
@@ -716,10 +726,623 @@ async function amapAround(origin, radiusKm, cfg) {
|
|||||||
}, TTL.poi);
|
}, TTL.poi);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overpass 兜底检索(免密钥)。
|
||||||
|
*
|
||||||
|
* 为什么需要:Photon 公共实例限流或临时挂掉时,候选池会掉到只剩内置库——
|
||||||
|
* 而内置库在广西、云南这类省的覆盖很薄(150km 内常常只有 1~3 个点),
|
||||||
|
* 第二步就几乎没内容可分。Overpass 直接查 OSM 的原始标签,是同源数据但不同入口。
|
||||||
|
*
|
||||||
|
* 代价:单次查询要 20~120 秒,所以**只在 Photon 全部失败时启用**,且走独立缓存。
|
||||||
|
*/
|
||||||
|
const OVERPASS_TAGS = [
|
||||||
|
{ sel: 'node["tourism"="attraction"]', kind: '景点', tags: ['人文历史', '拍照', '观光'] },
|
||||||
|
{ sel: 'node["tourism"="viewpoint"]', kind: '观景地', tags: ['自然风光', '拍照', '徒步登山'] },
|
||||||
|
{ sel: 'node["tourism"="museum"]', kind: '博物馆', tags: ['人文历史', '亲子', '室内避雨'] },
|
||||||
|
{ sel: 'way["tourism"="attraction"]', kind: '景点', tags: ['人文历史', '拍照', '观光'] },
|
||||||
|
{ sel: 'node["natural"="cave_entrance"]', kind: '溶洞', tags: ['自然风光', '探险', '避暑'] },
|
||||||
|
{ sel: 'node["historic"="memorial"]', kind: '纪念地', tags: ['人文历史', '室内避雨'] },
|
||||||
|
{ sel: 'node["leisure"="nature_reserve"]', kind: '自然保护地', tags: ['自然风光', '徒步登山'] },
|
||||||
|
{ sel: 'node["tourism"="theme_park"]', kind: '主题乐园', tags: ['亲子', '主题乐园'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function overpassQuery(origin, radiusKm, cfg) {
|
||||||
|
// bboxFromRadius 返回 [west, south, east, north](Photon 的顺序),
|
||||||
|
// 而 Overpass 要 [south, west, north, east] —— 顺序错了直接 400。
|
||||||
|
const [w, s, e, n] = U.bboxFromRadius(origin.lat, origin.lon, radiusKm);
|
||||||
|
const body = [s, w, n, e].map((x) => x.toFixed(4)).join(',');
|
||||||
|
const sel = OVERPASS_TAGS.map((t) => `${t.sel}(${body});`).join('');
|
||||||
|
const q = `[out:json][timeout:50];(${sel});out center 120;`;
|
||||||
|
// Overpass 公共实例对大 bbox + 多标签容易 502/504,换几个镜像重试
|
||||||
|
const hosts = Array.isArray(cfg.ENDPOINTS.overpass) ? cfg.ENDPOINTS.overpass : [cfg.ENDPOINTS.overpass];
|
||||||
|
const timeoutMs = Number(cfg.SEARCH?.overpassTimeoutMs) || 20000;
|
||||||
|
// 单次查询的总时限:镜像可能有 5 个,逐个等超时最坏要 5×timeout。
|
||||||
|
// 给一份总预算,超了就别再试下一个镜像了。
|
||||||
|
const deadline = Date.now() + timeoutMs * 2;
|
||||||
|
let lastErr = '';
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
lastErr = lastErr || 'Overpass 超出总时限';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(host, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'TripSense/1.0' },
|
||||||
|
body: 'data=' + encodeURIComponent(q),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
// 实例可能返回 200 + HTML 错误页(如 osm.ch 的 duplicate_query),
|
||||||
|
// 只看 status 会把错误页当成功,判据必须是响应体是否以 '{' 开头。
|
||||||
|
if (!text.startsWith('{')) throw new Error(`${host} 返回 ${res.status}`);
|
||||||
|
return JSON.parse(text).elements || [];
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e.message || String(e);
|
||||||
|
console.warn('[overpass]', lastErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(lastErr || 'Overpass 全部镜像均失败');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Overpass 的兴趣点大多**没有** `addr:*` 标签,直接拿归属会得到空组名。
|
||||||
|
*
|
||||||
|
* 解法:用 Overpass 的 `is_in` 反查**点落在哪些行政区里**。
|
||||||
|
* 一次请求就能拿到完整层级(实测 25.1,107.98 → L6 环江毛南族自治县 / L5 河池市 / L4 广西)。
|
||||||
|
*
|
||||||
|
* 为什么不用「拉边界多边形再本地做点面判定」:
|
||||||
|
* relation 的几何在 `members` 里,`out tags geom` / `out geom` 都拿不到
|
||||||
|
* relation 级的 geometry 数组;实测 `out tags geom` 返回的 1064 条元素
|
||||||
|
* 只有 type/id/bounds/tags,geometry 为 undefined。绕这一圈代价太高。
|
||||||
|
*
|
||||||
|
* 层级:OSM 中国的 `admin_level` 是 **4=省 / 5=地级市 / 6=县**,
|
||||||
|
* 不是直觉上的 4/6/8(实测荔波县是 6)。
|
||||||
|
*/
|
||||||
|
async function overpassAdminAreas(origin, radiusKm, cfg) {
|
||||||
|
// 保留旧签名:这里改为「一次拉 bbox 内所有行政区 + 打点标记」,不再逐点查
|
||||||
|
const [w, s, e, n] = U.bboxFromRadius(origin.lat, origin.lon, radiusKm);
|
||||||
|
const body = [s, w, n, e].map((x) => x.toFixed(4)).join(',');
|
||||||
|
const q = `[out:json][timeout:60];rel["admin_level"~"^[56]$"](${body});out center tags;`;
|
||||||
|
const hosts = Array.isArray(cfg.ENDPOINTS.overpass) ? cfg.ENDPOINTS.overpass : [cfg.ENDPOINTS.overpass];
|
||||||
|
const timeoutMs = Number(cfg.SEARCH?.overpassTimeoutMs) || 20000;
|
||||||
|
const deadline = Date.now() + timeoutMs * 2;
|
||||||
|
let lastErr = '';
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (Date.now() > deadline) { lastErr = lastErr || 'Overpass 超出总时限'; break; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(host, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'TripSense/1.0' },
|
||||||
|
body: 'data=' + encodeURIComponent(q),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text.startsWith('{')) throw new Error(`${host} 返回 ${res.status}`);
|
||||||
|
const els = JSON.parse(text).elements || [];
|
||||||
|
const areas = [];
|
||||||
|
for (const el of els) {
|
||||||
|
const name = String(el.tags?.['name:zh'] || el.tags?.name || '').trim();
|
||||||
|
if (!name) continue;
|
||||||
|
const c = el.center || {};
|
||||||
|
const lat = Number(c.lat);
|
||||||
|
const lon = Number(c.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||||
|
areas.push({ name, level: String(el.tags.admin_level), lat, lon });
|
||||||
|
}
|
||||||
|
return areas;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e.message || String(e);
|
||||||
|
console.warn('[overpass-admin]', lastErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(lastErr || 'Overpass 行政区查询失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐点反查所在行政区(Overpass `is_in`)。
|
||||||
|
* 只在 Overpass 兜底路径上、且候选缺少 addr:* 标签时用(一次约 1~3 秒)。
|
||||||
|
* @returns {{ area: string, detail: string } | null}
|
||||||
|
*/
|
||||||
|
async function overpassAdminAtPoint(lat, lon, cfg) {
|
||||||
|
const q = `[out:json][timeout:60];is_in(${lat.toFixed(5)},${lon.toFixed(5)})->.a;.a out tags;`;
|
||||||
|
const hosts = Array.isArray(cfg.ENDPOINTS.overpass) ? cfg.ENDPOINTS.overpass : [cfg.ENDPOINTS.overpass];
|
||||||
|
const timeoutMs = Number(cfg.SEARCH?.overpassTimeoutMs) || 20000;
|
||||||
|
for (const host of hosts) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(host, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'TripSense/1.0' },
|
||||||
|
body: 'data=' + encodeURIComponent(q),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text.startsWith('{')) continue;
|
||||||
|
const els = JSON.parse(text).elements || [];
|
||||||
|
let prefecture = '';
|
||||||
|
let county = '';
|
||||||
|
for (const el of els) {
|
||||||
|
const level = String(el.tags?.admin_level || '');
|
||||||
|
const name = String(el.tags?.['name:zh'] || el.tags?.name || '').trim();
|
||||||
|
if (!name) continue;
|
||||||
|
if (level === '5') prefecture = name;
|
||||||
|
else if (level === '6') county = name;
|
||||||
|
}
|
||||||
|
if (!prefecture && !county) continue;
|
||||||
|
// 走统一查表:OSM 的名字可能是简称(「环江」)或全称(「环江毛南族自治县」),
|
||||||
|
// 查表能补齐标准全称并带上所属地级;同名县还要按坐标消歧。
|
||||||
|
const cHit = county ? lookupAdminByName(county, lat, lon) : null;
|
||||||
|
if (cHit && cHit.detail) return { area: cHit.area, detail: cHit.detail };
|
||||||
|
const pHit = prefecture ? lookupAdminByName(prefecture, lat, lon) : null;
|
||||||
|
if (pHit) return { area: pHit.area, detail: pHit.detail || '' };
|
||||||
|
// 查表查不到就原样返回 OSM 名字,至少还有个能显示的地名
|
||||||
|
return { area: prefecture || county, detail: county && county !== prefecture ? county : '' };
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[overpass-isin]', e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrefectureSuffix = (n) => /(市|州|盟|地区)$/.test(String(n || '').trim());
|
||||||
|
const isCountySuffix = (n) => /(县|区|旗)$/.test(String(n || '').trim());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按坐标找所属行政区(Overpass 兜底路径专用)。
|
||||||
|
*
|
||||||
|
* 输入是 bbox 内所有行政区 relation 的 **center 点**(不是多边形——
|
||||||
|
* relation 级 geometry 在 Overpass 里拿不到,见 overpassAdminAreas 注释)。
|
||||||
|
* 所以判定退化��「找最近的同层级行政区」,并用 admin_level 区分地级/县级:
|
||||||
|
* L5=地级市、L6=县。
|
||||||
|
*
|
||||||
|
* @returns {{ area: string, detail: string } | null}
|
||||||
|
*/
|
||||||
|
function areaByPoint(areas, lat, lon) {
|
||||||
|
let prefecture = null;
|
||||||
|
let county = null;
|
||||||
|
for (const a of areas) {
|
||||||
|
const d = U.haversine(lat, lon, a.lat, a.lon);
|
||||||
|
if (a.level === '5' || isPrefectureSuffix(a.name)) {
|
||||||
|
if (!prefecture || d < prefecture.d) prefecture = { name: a.name, d };
|
||||||
|
} else if (a.level === '6' || isCountySuffix(a.name)) {
|
||||||
|
if (!county || d < county.d) county = { name: a.name, d };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 近邻判定给的是 OSM 的名字(可能是简称),过一遍统一查表补全标准全称
|
||||||
|
if (county) {
|
||||||
|
const hit = lookupAdminByName(county.name, lat, lon);
|
||||||
|
if (hit && hit.detail) return { area: hit.area, detail: hit.detail };
|
||||||
|
}
|
||||||
|
if (prefecture) {
|
||||||
|
const hit = lookupAdminByName(prefecture.name, lat, lon);
|
||||||
|
if (hit) return { area: hit.area, detail: hit.detail || (county ? county.name : '') };
|
||||||
|
}
|
||||||
|
if (prefecture) return { area: prefecture.name, detail: county ? county.name : '' };
|
||||||
|
if (county) return { area: county.name, detail: '' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overpassCandidate(el, origin, cfg) {
|
||||||
|
const t = el.tags || {};
|
||||||
|
const name = String(t['name:zh'] || t.name || '').trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const lat = Number(el.lat ?? el.center?.lat);
|
||||||
|
const lon = Number(el.lon ?? el.center?.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
// 按标签找类别
|
||||||
|
const tag = OVERPASS_TAGS.find((x) => x.sel.includes(`"${t.tourism || t.natural || t.historic || t.leisure}"`));
|
||||||
|
const kind = tag?.kind || '景点';
|
||||||
|
const tags = tag?.tags || ['自然风光', '拍照'];
|
||||||
|
const distanceKm = U.haversine(origin.lat, origin.lon, lat, lon);
|
||||||
|
return {
|
||||||
|
id: `overpass-${el.type}-${el.id}`,
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
tags: [...tags],
|
||||||
|
lat: U.round(lat, 5),
|
||||||
|
lon: U.round(lon, 5),
|
||||||
|
osmCounty: String(t['addr:county'] || t['addr:city'] || t['addr:province'] || '').trim(),
|
||||||
|
osmCity: String(t['addr:district'] || t['addr:city'] || '').trim(),
|
||||||
|
region: [t['addr:province'], t['addr:city'], t['addr:district']].filter(Boolean).join(' · '),
|
||||||
|
distanceKm,
|
||||||
|
bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon),
|
||||||
|
source: 'OpenStreetMap / Overpass',
|
||||||
|
hasExtent: false,
|
||||||
|
featured: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检索周边目的地(多层来源,任一不可用都不影响出结果):
|
* 检索周边目的地(多层来源,任一不可用都不影响出结果):
|
||||||
* Photon 分类检索 → 高德周边 POI(可选) → 内置知名景区 → 内置城市库 → 内置小众地点库
|
* Photon 分类检索 → 高德周边 POI(可选) → 内置知名景区 → 内置城市库 → 内置小众地点库
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 候选的归属地名(第二步分组用)。
|
||||||
|
*
|
||||||
|
* 问题:候选来自四个来源,归属信息粒度完全不一致——
|
||||||
|
* 高德 place/text → 有 cityname(地级市)与 adname(区县)
|
||||||
|
* 内置小众库 → region 是「省 · 市」
|
||||||
|
* 内置知名库 → 没有任何归属信息
|
||||||
|
* Photon(OSM) → region 是街道名(「金城江街道」)
|
||||||
|
* 直接把这些 region 铺在卡片上,用户看到的就是一堆平铺的名字,
|
||||||
|
* 分不清「柳州」和「柳州古城」是两级还是并列。
|
||||||
|
*
|
||||||
|
* 做法:统一反查成「地级市 + 区县」,作为分组键。
|
||||||
|
* 高德 regeo 的批量参数(batch=true)实测返回 INVALID_PARAMS,
|
||||||
|
* 所以逐点反查 + 缓存(TTL.geo,坐标取整 3 位做指纹)。
|
||||||
|
*/
|
||||||
|
async function amapPlaceOf(lat, lon, cfg) {
|
||||||
|
if (!cfg.AMAP_KEY) return null;
|
||||||
|
const [glng, glat] = U.wgs84ToGcj02(lon, lat);
|
||||||
|
return C.memo(sk(`placeOf:${U.round(lat, 3)},${U.round(lon, 3)}`, cfg), async () => {
|
||||||
|
const u = new URL(cfg.ENDPOINTS.amapRegeo);
|
||||||
|
u.searchParams.set('location', `${U.round(glng, 6)},${U.round(glat, 6)}`);
|
||||||
|
u.searchParams.set('extensions', 'base');
|
||||||
|
u.searchParams.set('key', cfg.AMAP_KEY);
|
||||||
|
const d = await U.fetchJSON(u.toString(), {}, 8000, 1);
|
||||||
|
if (d.status !== '1' || !d.regeocode) return null;
|
||||||
|
const ac = d.regeocode.addressComponent || {};
|
||||||
|
const province = String(ac.province || '').trim();
|
||||||
|
const city = String(ac.city || '').trim();
|
||||||
|
const district = String(ac.district || '').trim();
|
||||||
|
if (!city && !district) return null;
|
||||||
|
return { province, city: city || district, district: city && district ? district : '', label: district && district !== city ? `${city} · ${district}` : city };
|
||||||
|
}, TTL.geo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分组键归一:去掉末尾的「市 / 地区 / 盟 / 自治州」等行政后缀。
|
||||||
|
* 「柳州」(内置小众库写法)与「柳州市」(高德 cityname)必须落到同一组,
|
||||||
|
* 否则同一个地方会被拆成两堆。
|
||||||
|
*
|
||||||
|
* 民族自治州要连民族名一起去掉:「黔南布依族苗族自治州」→「黔南」,
|
||||||
|
* 「黔东南苗族侗族自治州」→「黔东南」。但**不能用贪婪正则**——
|
||||||
|
* `[一-龥]{1,3}族` 会把「黔南」的「南」也当成民族名吃掉(实测变成「黔」)。
|
||||||
|
* 所以这里用显式的民族名清单。
|
||||||
|
*/
|
||||||
|
const ETHNIC_AUTONOMY = /(?:壮族|满族|回族|苗族|侗族|土家族|羌族|彝族|蒙古族|藏族|维吾尔族|哈萨克族|柯尔克孜族|布依族|朝鲜族|白族|傣族|黎族|傈僳族|佤族|畲族|高山族|拉祜族|水族|东乡族|纳西族|景颇族|土族|达斡尔族|仫佬族|羌族|布朗族|撒拉族|毛南族|仡佬族|锡伯族|阿昌族|普米族|塔吉克族|怒族|乌孜别克族|俄罗斯族|鄂温克族|德昂族|保安族|裕固族|京族|塔塔尔族|独龙族|鄂伦春族|赫哲族|门巴族|珞巴族|基诺族)+自治(?:州|县)/g;
|
||||||
|
|
||||||
|
function placeKey(city) {
|
||||||
|
return String(city || '').trim()
|
||||||
|
.replace(ETHNIC_AUTONOMY, '')
|
||||||
|
.replace(/(自治区|自治州|自治县|地区|盟|市)$/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给候选补 place / placeKey / placeDetail:
|
||||||
|
* place 分组显示名(地级市/州,如「黔南州」「柳州市」)
|
||||||
|
* placeKey 分组键(归一后的地名,用于合并不同写法)
|
||||||
|
* placeDetail 更细一级的区县说明
|
||||||
|
*
|
||||||
|
* 分组名必须落在**地级**这一层,不能是街道或村名。
|
||||||
|
* 用户看到「玉屏街道」「乐依村」这种组名等于没分组——它是 OSM 的最细一级,
|
||||||
|
* 同一个县下面的几十个点会被拆成几十组。
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 查内置地点库(知名景区 / 小众地点)的离线归属地名。
|
||||||
|
* 数据由 tools/build-admin*.cjs 生成:Photon 按名检索 + 坐标距离消歧 + 归一到地级。
|
||||||
|
*
|
||||||
|
* detail 只保留**县级**(以「县/区/市/州/旗」结尾):
|
||||||
|
* Photon 有时把村名(「摆别村」)塞在这一级,展示出来反而更乱。
|
||||||
|
*/
|
||||||
|
function adminAreaOf(c) {
|
||||||
|
const table = ADMIN.SPOT_AREA;
|
||||||
|
if (!table) return null;
|
||||||
|
const key = c.id && c.id.startsWith('curated-')
|
||||||
|
? `scenic:${c.id.slice('curated-'.length)}`
|
||||||
|
: (c.id && c.id.startsWith('niche-') ? `niche:${c.id.slice('niche-'.length)}` : null);
|
||||||
|
if (!key) return null;
|
||||||
|
const hit = table[key];
|
||||||
|
if (!hit || !hit.area) return null;
|
||||||
|
const detail = /(县|区|市|州|旗|盟)$/.test(String(hit.detail || '').trim()) ? hit.detail : '';
|
||||||
|
return { area: hit.area, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 就近最近的地级市/州:全国地级坐标表 + 内置城市库 + 离线归属表里的地名坐标。
|
||||||
|
* 用 haversine 在本地算,不发请求(几十个点 × 几百个地名的规模完全够用)。
|
||||||
|
*
|
||||||
|
* 只认**地级**名字(以「市/州/盟/地区」结尾)。
|
||||||
|
* 县级名(从江县、吉县)当组名会让同一个地级市被拆成好几组,
|
||||||
|
* 也会出现「荔波小七孔归到隔壁的从江县」这种误导。
|
||||||
|
*
|
||||||
|
* 全国表(lib/places-admin-dv.js,343 个地级)是主力;CN_CITIES 只有 146 个,
|
||||||
|
* 桂黔边境(河池市、黔南州、黔东南州)根本不在里面,缺了它候选点会落进空组名。
|
||||||
|
*/
|
||||||
|
function isPrefectureName(name) {
|
||||||
|
return /(市|州|盟|地区)$/.test(String(name || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「下层地名」是否合格:必须是县级(县/区/旗/市)或地级,不能是村/街道/屯。
|
||||||
|
* Photon 的 city/district 字段层级不稳,会给出「乐依村」「双福」「陈家甫」这种
|
||||||
|
* 村名甚至门牌名——挂在分组标题下当区县完全没法看。
|
||||||
|
*/
|
||||||
|
function isDistrictName(name) {
|
||||||
|
return /(县|区|旗|盟|州|市)$/.test(String(name || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个名字是不是**已知的地级行政区**。
|
||||||
|
*
|
||||||
|
* 用于过滤 Photon `osm.place.city` 检索里的噪声:OSM 的 place=city 里
|
||||||
|
* 混着大量景点 POI(牌坊、寺庙、老街),它们的名字也带「市/州」后缀
|
||||||
|
* 会被误当行政区。查一下官方表就知道是不是真地名。
|
||||||
|
*/
|
||||||
|
function isKnownPrefecture(name) {
|
||||||
|
const raw = String(name || '').trim();
|
||||||
|
if (!raw) return false;
|
||||||
|
if (DV.PREF_DV[raw]) return true;
|
||||||
|
const alias = DV.PREF_ALIAS[raw];
|
||||||
|
return !!(alias && DV.PREF_DV[alias]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestArea(lat, lon) {
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
let best = null;
|
||||||
|
const scan = (name, coord) => {
|
||||||
|
if (!coord || !isPrefectureName(name)) return;
|
||||||
|
const d = U.haversine(lat, lon, coord[0], coord[1]);
|
||||||
|
if (!best || d < best.d) best = { area: name, d };
|
||||||
|
};
|
||||||
|
// DataV 官方地级表(343 个,含各族自治州简称)优先
|
||||||
|
for (const [name, coord] of Object.entries(DV.PREF_DV || {})) scan(name, coord);
|
||||||
|
for (const [name, la, lo] of CN_CITIES) scan(name, [la, lo]);
|
||||||
|
for (const [name, coord] of Object.entries(ADMIN.AREA_COORD || {})) scan(name, coord);
|
||||||
|
// 最近的超过 120km 基本就不是「这一片」,宁可留空也不要给出误导性的地名
|
||||||
|
return best && best.d <= 120 ? { area: best.area } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从离线表里取一个县的条目,同名县按距离选最近的。
|
||||||
|
*
|
||||||
|
* DataV 官方表里同名县保留为**数组**(全国 30 组:「城中区」柳州/西宁都有、
|
||||||
|
* 「鼓楼区」南京/徐州/福州/开封都有、「铁西区」沈阳/鞍山/四平都有)。
|
||||||
|
* 只按名字取会把柳州的点归到西宁去,所以必须带坐标做消歧。
|
||||||
|
*
|
||||||
|
* @param {string} name 县全称(表里的 key)
|
||||||
|
* @param {number} lat 候选点纬度
|
||||||
|
* @param {number} lon 候选点经度
|
||||||
|
* @returns {[number, number, string, string] | null} [纬度, 经度, 所属地级, adcode]
|
||||||
|
*/
|
||||||
|
function pickCounty(name, lat, lon) {
|
||||||
|
const v = DV.COUNTY_DV[name];
|
||||||
|
if (!v) return null;
|
||||||
|
const list = (Array.isArray(v) && Array.isArray(v[0])) ? v : [v];
|
||||||
|
if (list.length === 1) return list[0];
|
||||||
|
let best = null;
|
||||||
|
for (const c of list) {
|
||||||
|
const d = U.haversine(lat, lon, c[0], c[1]);
|
||||||
|
if (!best || d < best.d) best = { c, d };
|
||||||
|
}
|
||||||
|
return best ? best.c : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 就近最近的县级行政区(DataV 官方县级表)。
|
||||||
|
* 县比地级小得多,所以「最近县」比「最近地级」精确得多——桂黔边境
|
||||||
|
* 荔波县与河池市相距不到 40km,按地级中心点会分错,按县不会。
|
||||||
|
*
|
||||||
|
* 注意:这是**兜底**路径,主力是按名字精确查(lookupAdminByName)。
|
||||||
|
* 按中心点距离在狭长县上仍会判错(环江县中心点远在贵州方向)。
|
||||||
|
*/
|
||||||
|
function nearestCounty(lat, lon) {
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
let best = null;
|
||||||
|
for (const [name, coord] of Object.entries(DV.COUNTY_DV || {})) {
|
||||||
|
if (!coord) continue;
|
||||||
|
// 同名县在表里是数组(每个候选各自有中心点),要展平后逐个比距离
|
||||||
|
const list = (Array.isArray(coord) && Array.isArray(coord[0])) ? coord : [coord];
|
||||||
|
for (const c of list) {
|
||||||
|
if (!c) continue;
|
||||||
|
const d = U.haversine(lat, lon, c[0], c[1]);
|
||||||
|
if (!best || d < best.d) best = { county: name, d, prefecture: c[2] || '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 60km 外基本就不是「这一片」了,县级行政区一般也就横跨 30~60km
|
||||||
|
return best && best.d <= 60 ? best : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由「最近县」推导所属地级。
|
||||||
|
* 优先用 GB/T 2260 编码前缀(县级表第三项,已离线算好),
|
||||||
|
* 没有编码时退回「按坐标找最近地级」。
|
||||||
|
*/
|
||||||
|
function prefectureOfCounty(countyHit, lat, lon) {
|
||||||
|
if (countyHit && countyHit.prefecture) return countyHit.prefecture;
|
||||||
|
const near = nearestArea(lat, lon);
|
||||||
|
return near ? near.area : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按**名称**精确查行政区(不靠距离)。
|
||||||
|
*
|
||||||
|
* 这是归属判定的主力路径:OSM 的 `addr:county` / Photon 的 `county`
|
||||||
|
* 有时直接就是标准县名(「环江毛南族自治县」「大化瑶族自治县」),
|
||||||
|
* 此时按名字查官方表是 100% 准确的,比「按中心点找最近的县」可靠得多
|
||||||
|
* (桂黔边境的狭长县,中心点距离会判到隔壁省去)。
|
||||||
|
*
|
||||||
|
* **必须带距离校验**:全国有同名县(「城中区」柳州西宁都有、
|
||||||
|
* 「鼓楼区」福州南京都有),只看名字会把柳州的点归到西宁去。
|
||||||
|
* 查到的县离候选点太远(>80km)就判定为不可信,退回按坐标近邻。
|
||||||
|
*
|
||||||
|
* @param {number} lat 候选点纬度
|
||||||
|
* @param {number} lon 候选点经度
|
||||||
|
* @returns {{ area: string, detail: string } | null}
|
||||||
|
*/
|
||||||
|
function lookupAdminByName(name, lat, lon) {
|
||||||
|
const raw = String(name || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
// 县级:全称 → 别名
|
||||||
|
const isExact = !!DV.COUNTY_DV[raw];
|
||||||
|
const full = isExact ? raw : (DV.COUNTY_ALIAS[raw] || null);
|
||||||
|
if (full && DV.COUNTY_DV[full]) {
|
||||||
|
// 同名县在表里是数组,按坐标取最近的那个(否则柳州的「城中区」会落到西宁)
|
||||||
|
const c = pickCounty(full, lat, lon);
|
||||||
|
if (!c) return null;
|
||||||
|
// 距离阈值分两档:
|
||||||
|
// 精确全称命中(OSM 明确写了这个县名)→ 可信度高,放宽到 150km。
|
||||||
|
// 狭长县的中心点离边界可达 80km 以上(大化县中心在西北角,
|
||||||
|
// 东南角的响水关离中心 85km),按 80km 一刀切会把正确的县名丢掉。
|
||||||
|
// 别名/简称命中 → 只说明大致是这片地方,严格一点(80km)。
|
||||||
|
const limitKm = (full === raw) ? 150 : 80;
|
||||||
|
if (tooFar(lat, lon, c, limitKm)) return null; // 同名县但离得远 → 不可信
|
||||||
|
// 同名县组(如「城中区」有 2 个候选)必须选**距离最近**的那个:
|
||||||
|
// pickCounty 已经按距离挑了,但还要确认它明显比同组其他候选更近,
|
||||||
|
// 否则说明 OSM 这个名字可能是别名/误填。
|
||||||
|
const list = DV.COUNTY_DV[full];
|
||||||
|
if (Array.isArray(list) && Array.isArray(list[0])) {
|
||||||
|
const sorted = list
|
||||||
|
.map((x) => ({ x, d: U.haversine(lat, lon, x[0], x[1]) }))
|
||||||
|
.sort((a, b) => a.d - b.d);
|
||||||
|
// 最近的这个比第二名远 1.5 倍以上 → 判为误匹配
|
||||||
|
if (sorted[1] && sorted[0].d > sorted[1].d * 1.5 && sorted[0].d > 30) return null;
|
||||||
|
}
|
||||||
|
return { area: c[2], detail: full };
|
||||||
|
}
|
||||||
|
// 名字本身是地级(OSM 常常把地级填进 county 字段)
|
||||||
|
const pref = DV.PREF_DV[raw] ? raw : (DV.PREF_ALIAS[raw] || null);
|
||||||
|
if (pref && DV.PREF_DV[pref]) {
|
||||||
|
const p = DV.PREF_DV[pref];
|
||||||
|
// 地级可以很大(新疆的市跨几百公里),阈值放宽
|
||||||
|
if (!tooFar(lat, lon, p, 200)) return { area: pref, detail: '' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查到的行政区离候选点是否太远(超过 limitKm 判为同名误匹配)。
|
||||||
|
* 县一般横跨 30~60km,80km 是上限;地级市可以很大,用 200km。
|
||||||
|
*/
|
||||||
|
function tooFar(lat, lon, coord, limitKm) {
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||||
|
if (!coord) return false;
|
||||||
|
return U.haversine(lat, lon, coord[0], coord[1]) > limitKm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachPlaces(candidates, cfg) {
|
||||||
|
const need = candidates.filter((c) => !c.city && c.kind !== '城市');
|
||||||
|
if (need.length && cfg.AMAP_KEY) {
|
||||||
|
// 串行 + 间隔:高德 regeo 并发 3 就会限流,失败的候选会退回街道名当分组名
|
||||||
|
// (出现过「坡进屯」「内塘屯」这种没法看的一组)。实测串行 20 个约 730ms。
|
||||||
|
for (const c of need) {
|
||||||
|
try {
|
||||||
|
const p = await amapPlaceOf(c.lat, c.lon, cfg);
|
||||||
|
if (p) {
|
||||||
|
c.city = p.city;
|
||||||
|
c.district = p.district || c.district || '';
|
||||||
|
c.province = p.province || '';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[attachPlaces]', c.name, err.message);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const c of candidates) {
|
||||||
|
// 内置城市库:名字本身就是地名(柳州市),它是这一组的地名节点。
|
||||||
|
//
|
||||||
|
// 但「城市」这个 kind 也可能来自 Photon 的 `osm.place.city` 检索,
|
||||||
|
// 而 OSM 的 place=city 里混着大量**景点 POI**(牌坊、寺庙、老街)。
|
||||||
|
// 那些名字不是行政区,不能拿来当组名——否则会出现
|
||||||
|
// 「社更穿洞北门」自己成一组、「柳州之门」自己成一组这种荒唐结果。
|
||||||
|
// 所以必须先校验名字确实是行政区名,否则走下面的正常归属流程。
|
||||||
|
if (c.kind === '城市' && isPrefectureName(c.name) && isKnownPrefecture(c.name)) {
|
||||||
|
c.place = c.name;
|
||||||
|
c.placeKey = placeKey(c.name) || c.name;
|
||||||
|
c.placeDetail = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const city = String(c.city || '').trim();
|
||||||
|
const district = String(c.district || '').trim();
|
||||||
|
if (city) {
|
||||||
|
c.place = city;
|
||||||
|
c.placeKey = placeKey(city) || city;
|
||||||
|
c.placeDetail = district && district !== city && isDistrictName(district) ? district : '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 内置库(知名景区 / 小众地点):查离线归属表,最准,优先用
|
||||||
|
const hit = adminAreaOf(c);
|
||||||
|
if (hit) {
|
||||||
|
c.place = hit.area;
|
||||||
|
c.placeKey = placeKey(hit.area) || hit.area;
|
||||||
|
c.placeDetail = hit.detail || '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSM 系(Photon / Overpass):优先用 OSM 自带的归属字段**按名字精确查**。
|
||||||
|
//
|
||||||
|
// 为什么不能一律「按坐标找最近的县」:桂黔边境的县界狭长,
|
||||||
|
// 「环江喀斯特世界自然遗产」实际在环江县(河池市),但按中心点距离
|
||||||
|
// 会判到隔壁的荔波县(黔南州)——差 45km 完全够跨省。
|
||||||
|
// OSM 的 addr:county 在多数条目上直接写了标准县名,按名字查是准的。
|
||||||
|
//
|
||||||
|
// OSM 的层级字段本身不可靠(county 有时填县名、有时填地级,
|
||||||
|
// city 有时填村名),所以三个字段都试一遍,取第一个查得到的。
|
||||||
|
if (c.osmCounty || c.osmCity || c.osmDistrict) {
|
||||||
|
// 先在所有字段里找**县级**(下层地名),找不到再退到地级。
|
||||||
|
// OSM 常常把地级填进 county、把县名填进 city(「黔南州」+「荔波县」),
|
||||||
|
// 若只看第一个字段就定案,下层地名会丢。
|
||||||
|
let countyHitName = null;
|
||||||
|
let areaHit = null;
|
||||||
|
for (const raw of [c.osmCounty, c.osmCity, c.osmDistrict]) {
|
||||||
|
if (!raw) continue;
|
||||||
|
const hit = lookupAdminByName(raw, c.lat, c.lon);
|
||||||
|
if (!hit) continue;
|
||||||
|
if (hit.detail) { countyHitName = hit.detail; areaHit = hit.area; break; }
|
||||||
|
if (!areaHit) areaHit = hit.area;
|
||||||
|
}
|
||||||
|
if (areaHit) {
|
||||||
|
c.place = areaHit;
|
||||||
|
c.placeKey = placeKey(areaHit) || areaHit;
|
||||||
|
c.placeDetail = countyHitName && countyHitName !== areaHit ? countyHitName : '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 名字查不到:退回按坐标找最近的县
|
||||||
|
const countyHit = nearestCounty(c.lat, c.lon);
|
||||||
|
const area = countyHit ? prefectureOfCounty(countyHit, c.lat, c.lon) : '';
|
||||||
|
if (area) {
|
||||||
|
c.place = area;
|
||||||
|
c.placeKey = placeKey(area) || area;
|
||||||
|
const detail = countyHit ? countyHit.county : (isDistrictName(c.osmCity) ? c.osmCity : '');
|
||||||
|
c.placeDetail = detail && detail !== area ? detail : '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 县级表没覆盖到:退回 OSM 的 county 字段,但必须校验它是地级
|
||||||
|
const area2 = isPrefectureName(c.osmCounty) ? c.osmCounty : '';
|
||||||
|
if (area2) {
|
||||||
|
c.place = area2;
|
||||||
|
c.placeKey = placeKey(area2) || area2;
|
||||||
|
c.placeDetail = c.osmCity && c.osmCity !== area2 && isDistrictName(c.osmCity) ? c.osmCity : '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c.osmCity) {
|
||||||
|
const lift = nearestArea(c.lat, c.lon);
|
||||||
|
c.place = lift ? lift.area : c.osmCity;
|
||||||
|
c.placeKey = placeKey(c.place) || c.place;
|
||||||
|
c.placeDetail = lift && isDistrictName(c.osmCity) ? c.osmCity : '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后兜底:按坐标找最近的地级市(全国地级表 + 内置城市库)
|
||||||
|
const near = nearestArea(c.lat, c.lon);
|
||||||
|
if (near) {
|
||||||
|
c.place = near.area;
|
||||||
|
c.placeKey = placeKey(near.area) || near.area;
|
||||||
|
c.placeDetail = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 实在查不到:退回内置库的 region,至少不丢信息
|
||||||
|
const seg = String(c.region || '').split('·').map((s) => s.trim()).filter(Boolean);
|
||||||
|
c.place = seg.length ? seg[seg.length - 1] : '';
|
||||||
|
c.placeKey = placeKey(c.place) || c.place;
|
||||||
|
c.placeDetail = seg.length > 1 ? seg[0] : '';
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
async function discoverCandidates(origin, radiusKm, cfg, opts = {}) {
|
async function discoverCandidates(origin, radiusKm, cfg, opts = {}) {
|
||||||
// 候选发现缓存键:起点 + 半径 + 排除同城设置。Photon 限流严重,重复分析时必须复用。
|
// 候选发现缓存键:起点 + 半径 + 排除同城设置。Photon 限流严重,重复分析时必须复用。
|
||||||
const dkey = `disc:${U.round(origin.lat, 3)},${U.round(origin.lon, 3)}:${Math.round(radiusKm)}:${opts.excludeLocal !== false}:${opts.localKm || ''}:${opts.homeDistrict || ''}:${opts.homeCity || ''}`;
|
const dkey = `disc:${U.round(origin.lat, 3)},${U.round(origin.lon, 3)}:${Math.round(radiusKm)}:${opts.excludeLocal !== false}:${opts.localKm || ''}:${opts.homeDistrict || ''}:${opts.homeCity || ''}`;
|
||||||
@@ -767,6 +1390,76 @@ async function discoverCandidates(origin, radiusKm, cfg, opts = {}) {
|
|||||||
}
|
}
|
||||||
for (const c of amapPois || []) pool.push(c);
|
for (const c of amapPois || []) pool.push(c);
|
||||||
|
|
||||||
|
// Overpass 兜底:只在 Photon 全部失败时才启用(单次查询要 20~120 秒)
|
||||||
|
let overpassCount = 0;
|
||||||
|
let overpassError = '';
|
||||||
|
if (photonOk === 0 && !(amapPois || []).length) {
|
||||||
|
// 兜底路径的总时限预算。公共实例经常「连着不返回」,
|
||||||
|
// 没有总预算的话一次分析能被拖成十几分钟(实测踩过)。
|
||||||
|
// 超时后直接放弃兜底,清单退回内置地名库(+ dataNotes 里说明实时检索不可用)。
|
||||||
|
const budgetMs = Math.max(10000, (Number(cfg.SEARCH?.overpassTimeoutMs) || 20000) * 4);
|
||||||
|
const deadline = Date.now() + budgetMs;
|
||||||
|
try {
|
||||||
|
const els = await overpassQuery(origin, radiusKm, cfg);
|
||||||
|
// 兴趣点大多没有 addr:* 标签,要靠行政区反查归属
|
||||||
|
let areas = null;
|
||||||
|
try {
|
||||||
|
areas = await overpassAdminAreas(origin, radiusKm, cfg);
|
||||||
|
} catch (e2) {
|
||||||
|
console.warn('[overpass] 行政区边界拿不到,归属将退化', e2.message);
|
||||||
|
}
|
||||||
|
// 逐点 is_in 反查是**最慢**的一环(每个点每个镜像都要等一次超时),
|
||||||
|
// 必须限量:超过上限后不再精确反查,剩下的点靠 bbox 行政区中心点近邻判定
|
||||||
|
// (近似,但一次请求覆盖全部候选,慢不到一个数量级)。
|
||||||
|
const isInMax = Number(cfg.SEARCH?.overpassIsInMax) || 8;
|
||||||
|
let isInUsed = 0;
|
||||||
|
for (const el of els) {
|
||||||
|
const c = overpassCandidate(el, origin, cfg);
|
||||||
|
if (!c) continue;
|
||||||
|
if (JUNK_NAME.test(c.name) || POOR_NAME.test(c.name) || LOW_VALUE_NAME.test(c.name)) continue;
|
||||||
|
if (c.distanceKm > radiusKm * 1.05) continue;
|
||||||
|
if (!c.osmCounty) {
|
||||||
|
// 先用 bbox 内的行政区中心点做近邻判定(一次请求覆盖全部)
|
||||||
|
let hit = areas ? areaByPoint(areas, c.lat, c.lon) : null;
|
||||||
|
// 近邻判定拿不到(中心点离边界太远)时,逐点用 is_in 精确反查
|
||||||
|
if (!hit && isInUsed < isInMax) {
|
||||||
|
isInUsed += 1;
|
||||||
|
// 超预算就停止精确反查,但**候选照常入池**:
|
||||||
|
// 归属还有离线官方表兜底(attachPlaces 会按坐标找最近的县),
|
||||||
|
// 不能因为反查超时就丢掉整个候选。
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
console.warn('[overpass] 兜底路径超出时限预算,停止精确反查');
|
||||||
|
isInUsed = isInMax;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
hit = await overpassAdminAtPoint(c.lat, c.lon, cfg);
|
||||||
|
} catch (e3) {
|
||||||
|
console.warn('[overpass-isin]', c.name, e3.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hit) {
|
||||||
|
// 字段摆位要和 attachPlaces 的读取顺序对齐:
|
||||||
|
// attachPlaces 是「先在 county/city/district 里找**县级**,找不到再退地级」,
|
||||||
|
// 所以县级名必须放 osmCounty、地级名放 osmCity。
|
||||||
|
// 反过来(地级放 county)会让兜底路径永远丢掉下层县名。
|
||||||
|
if (hit.detail) {
|
||||||
|
c.osmCounty = hit.detail;
|
||||||
|
c.osmCity = hit.area || '';
|
||||||
|
} else {
|
||||||
|
c.osmCity = hit.area || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pool.push(c);
|
||||||
|
overpassCount += 1;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
overpassError = e.message || String(e);
|
||||||
|
console.warn('[overpass]', overpassError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 内置知名目的地:既做兜底,也常年补入若干高辨识度地点
|
// 内置知名目的地:既做兜底,也常年补入若干高辨识度地点
|
||||||
let curatedCount = 0;
|
let curatedCount = 0;
|
||||||
for (const [name, lat, lon, kind, tagStr] of CURATED) {
|
for (const [name, lat, lon, kind, tagStr] of CURATED) {
|
||||||
@@ -905,11 +1598,16 @@ async function discoverCandidates(origin, radiusKm, cfg, opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分组:给每个候选补上「归属地名」,第二步按地名分组展示
|
||||||
|
await attachPlaces(selected, cfg);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
candidates: selected.map((c, i) => ({ ...c, index: i })),
|
candidates: selected.map((c, i) => ({ ...c, index: i })),
|
||||||
stats: {
|
stats: {
|
||||||
photonOk, categoryHits,
|
photonOk, categoryHits,
|
||||||
curatedCount, cityCount, nicheCount,
|
curatedCount, cityCount, nicheCount,
|
||||||
|
overpassCount,
|
||||||
|
overpassError: overpassError || '',
|
||||||
amapCount: (amapPois || []).length,
|
amapCount: (amapPois || []).length,
|
||||||
amapError: amapError || '',
|
amapError: amapError || '',
|
||||||
poolSize: unique.length,
|
poolSize: unique.length,
|
||||||
@@ -1292,5 +1990,5 @@ module.exports = {
|
|||||||
reverseGeocode, amapReverse, discoverCandidates, bestWeatherWindow,
|
reverseGeocode, amapReverse, discoverCandidates, bestWeatherWindow,
|
||||||
fetchWeather, fetchAQI, aqiLabel,
|
fetchWeather, fetchAQI, aqiLabel,
|
||||||
roadMatrix, railMatrix, estimateRoad, estimateRail,
|
roadMatrix, railMatrix, estimateRoad, estimateRail,
|
||||||
amapResolvePlace,
|
amapResolvePlace, attachPlaces, placeKey,
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
+130
-4
@@ -68,6 +68,12 @@ const state = {
|
|||||||
sort: 'score',
|
sort: 'score',
|
||||||
onlyRec: false,
|
onlyRec: false,
|
||||||
onlyNiche: false,
|
onlyNiche: false,
|
||||||
|
// 候选搜索关键词(按地名/景点名/标签过滤)
|
||||||
|
search: '',
|
||||||
|
// 第二步是否按「归属地名」分组展示
|
||||||
|
groupByPlace: true,
|
||||||
|
// 已折叠的地名分组
|
||||||
|
collapsedPlaces: new Set(),
|
||||||
compareOpen: false,
|
compareOpen: false,
|
||||||
autoPreselected: false,
|
autoPreselected: false,
|
||||||
datesTouched: false,
|
datesTouched: false,
|
||||||
@@ -490,7 +496,7 @@ function cardHtml(c) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<h4>${esc(c.name)}</h4>
|
<h4>${esc(c.name)}</h4>
|
||||||
<div class="sub">${esc(c.kind || '目的地')}${c.region ? ' · ' + esc(c.region) : ''} · ${compassCN(c.bearingDeg)}方向 ${fmtKm(c.distanceKm)}</div>
|
<div class="sub">${esc(c.kind || '目的地')}${c.placeDetail ? ' · ' + esc(c.placeDetail) : (c.region && !state.groupByPlace ? ' · ' + esc(c.region) : '')} · ${compassCN(c.bearingDeg)}方向 ${fmtKm(c.distanceKm)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="score-ring">${scoreRing(c.score)}</div>
|
<div class="score-ring">${scoreRing(c.score)}</div>
|
||||||
@@ -523,24 +529,121 @@ function sortedCandidates() {
|
|||||||
});
|
});
|
||||||
if (state.onlyRec) list = list.filter((c) => c.score >= 60);
|
if (state.onlyRec) list = list.filter((c) => c.score >= 60);
|
||||||
if (state.onlyNiche) list = list.filter((c) => c.niche);
|
if (state.onlyNiche) list = list.filter((c) => c.niche);
|
||||||
|
const q = (state.search || '').trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
// 搜索范围要覆盖分组名:搜「柳州」应该能命中「柳州古城」「融水双龙沟」
|
||||||
|
// 这类分属柳州/融水两个地名的条目,否则用户会以为搜不到
|
||||||
|
list = list.filter((c) => {
|
||||||
|
const hay = [
|
||||||
|
c.name, c.place, c.placeDetail, c.kind, c.region,
|
||||||
|
...(c.tags || []),
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按「归属地名」分组。
|
||||||
|
*
|
||||||
|
* 为什么要分组:候选里既有地名(柳州市、桂林市这类城市级目的地),
|
||||||
|
* 又有具体景点(柳州古城、融水双龙沟),平铺在一起看不出层级,
|
||||||
|
* 也不知道某个景点属于哪片地方。分组后是「地名 → 景点」两级。
|
||||||
|
*/
|
||||||
|
function groupByPlace(list) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const c of list) {
|
||||||
|
const key = c.placeKey || c.place || '其他';
|
||||||
|
if (!map.has(key)) map.set(key, { key, place: c.place || '其他', detail: '', items: [] });
|
||||||
|
const g = map.get(key);
|
||||||
|
g.items.push(c);
|
||||||
|
// 组副标题取出现最多的区县,作为该组的地理说明
|
||||||
|
if (c.placeDetail && !g.detail.includes(c.placeDetail)) {
|
||||||
|
g.detail = g.detail ? `${g.detail}、${c.placeDetail}` : c.placeDetail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groups = [...map.values()];
|
||||||
|
// 组内最高分高的排前面;同分按最近距离
|
||||||
|
groups.forEach((g) => {
|
||||||
|
g.best = g.items.reduce((m, c) => Math.max(m, c.score || 0), 0);
|
||||||
|
g.nearest = g.items.reduce((m, c) => Math.min(m, c.distanceKm ?? 1e9), 1e9);
|
||||||
|
});
|
||||||
|
groups.sort((a, b) => b.best - a.best || a.nearest - b.nearest);
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组标题:地名 + 景点数 + 该组最佳分与距离 */
|
||||||
|
function placeGroupHtml(g) {
|
||||||
|
const collapsed = state.collapsedPlaces.has(g.key);
|
||||||
|
const sample = g.items[0];
|
||||||
|
const dirs = [...new Set(g.items.map((c) => compassCN(c.bearingDeg)))];
|
||||||
|
const maxKm = Math.max(...g.items.map((c) => c.distanceKm || 0));
|
||||||
|
const minKm = Math.min(...g.items.map((c) => c.distanceKm || 0));
|
||||||
|
const distTxt = maxKm - minKm < 1
|
||||||
|
? fmtKm(minKm)
|
||||||
|
: `${fmtKm(minKm)} ~ ${fmtKm(maxKm)}`;
|
||||||
|
return `<section class="place-group" data-place="${esc(g.key)}">
|
||||||
|
<button type="button" class="place-head" data-place-toggle="${esc(g.key)}" aria-expanded="${collapsed ? 'false' : 'true'}">
|
||||||
|
<span class="place-caret">${collapsed ? '▸' : '▾'}</span>
|
||||||
|
<span class="place-name">${esc(g.place)}</span>
|
||||||
|
${g.detail ? `<span class="place-detail">${esc(g.detail)}</span>` : ''}
|
||||||
|
<span class="place-meta">${g.items.length} 个目的地 · 最佳 ${g.best} 分 · ${distTxt} · ${dirs.slice(0, 2).join('/')}方向</span>
|
||||||
|
</button>
|
||||||
|
<div class="place-items ${collapsed ? 'collapsed' : ''}">${g.items.map(cardHtml).join('')}</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCards() {
|
function renderCards() {
|
||||||
const list = sortedCandidates();
|
const list = sortedCandidates();
|
||||||
// 重渲染会重建 DOM:先记住哪些逐日天气是展开的,避免勾选/排序后莫名收起
|
// 重渲染会重建 DOM:先记住哪些逐日天气是展开的,避免勾选/排序后莫名收起
|
||||||
const openDays = new Set($$('#cards .daily[open]').map((d) => d.dataset.day));
|
const openDays = new Set($$('#cards .daily[open]').map((d) => d.dataset.day));
|
||||||
const noBase = !(state.analysis?.candidates || []).length;
|
const noBase = !(state.analysis?.candidates || []).length;
|
||||||
$('#cards').innerHTML = list.length
|
const total = (state.analysis?.candidates || []).length;
|
||||||
? list.map(cardHtml).join('')
|
const cardsBox = $('#cards');
|
||||||
: `<div class="empty-mini">${noBase
|
cardsBox.classList.toggle('has-groups', state.groupByPlace && list.length > 0);
|
||||||
|
if (list.length) {
|
||||||
|
cardsBox.innerHTML = state.groupByPlace
|
||||||
|
? groupByPlace(list).map(placeGroupHtml).join('')
|
||||||
|
: list.map(cardHtml).join('');
|
||||||
|
} else {
|
||||||
|
cardsBox.innerHTML = `<div class="empty-mini">${noBase
|
||||||
? `当前半径内没有可去的地点${state.analysis?.constraints?.excludeLocal ? '(已排除出发地附近的同城点位)' : ''},试试取消「排除同城」或增大搜索半径。`
|
? `当前半径内没有可去的地点${state.analysis?.constraints?.excludeLocal ? '(已排除出发地附近的同城点位)' : ''},试试取消「排除同城」或增大搜索半径。`
|
||||||
|
: state.search
|
||||||
|
? `没有匹配「${esc(state.search)}」的地点,换个关键词,或取消筛选条件。`
|
||||||
: state.onlyNiche
|
: state.onlyNiche
|
||||||
? '当前半径内没有小众地点,试试取消「只看小众」或增大搜索半径。'
|
? '当前半径内没有小众地点,试试取消「只看小众」或增大搜索半径。'
|
||||||
: '没有符合筛选条件的目的地,试试取消「仅显示推荐」或「只看小众」。'}</div>`;
|
: '没有符合筛选条件的目的地,试试取消「仅显示推荐」或「只看小众」。'}</div>`;
|
||||||
|
}
|
||||||
$$('#cards .daily').forEach((d) => { if (openDays.has(d.dataset.day)) d.open = true; });
|
$$('#cards .daily').forEach((d) => { if (openDays.has(d.dataset.day)) d.open = true; });
|
||||||
$$('#cards .pick-btn').forEach((b) => b.addEventListener('click', () => togglePick(b.dataset.id)));
|
$$('#cards .pick-btn').forEach((b) => b.addEventListener('click', () => togglePick(b.dataset.id)));
|
||||||
|
// 组标题折叠:只动 class,不重渲染(重渲染会丢失滚动位置与展开态)
|
||||||
|
$$('#cards [data-place-toggle]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const key = btn.dataset.placeToggle;
|
||||||
|
if (state.collapsedPlaces.has(key)) state.collapsedPlaces.delete(key);
|
||||||
|
else state.collapsedPlaces.add(key);
|
||||||
|
const sec = btn.closest('.place-group');
|
||||||
|
const items = sec?.querySelector('.place-items');
|
||||||
|
const collapsed = state.collapsedPlaces.has(key);
|
||||||
|
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||||
|
btn.querySelector('.place-caret').textContent = collapsed ? '▸' : '▾';
|
||||||
|
if (items) items.classList.toggle('collapsed', collapsed);
|
||||||
|
});
|
||||||
|
});
|
||||||
$('#selectedCount').textContent = `已选 ${state.selected.size} 个`;
|
$('#selectedCount').textContent = `已选 ${state.selected.size} 个`;
|
||||||
$('#preselectHint').classList.toggle('hidden', !state.autoPreselected);
|
$('#preselectHint').classList.toggle('hidden', !state.autoPreselected);
|
||||||
|
// 筛选提示条:告诉用户「当前显示多少 / 共多少」,否则筛完不知道漏了什么
|
||||||
|
const fn = $('#filterNote');
|
||||||
|
const filtered = list.length !== total || state.onlyRec || state.onlyNiche || state.search;
|
||||||
|
fn.classList.toggle('hidden', !filtered);
|
||||||
|
if (filtered) {
|
||||||
|
fn.innerHTML = `当前显示 <b>${list.length}</b> / ${total} 个目的地`
|
||||||
|
+ (state.search ? ` · 搜索「${esc(state.search)}」` : '')
|
||||||
|
+ (state.onlyRec ? ' · 仅推荐及以上' : '')
|
||||||
|
+ (state.onlyNiche ? ' · 仅小众' : '')
|
||||||
|
+ (state.groupByPlace ? ` · 已按 ${groupByPlace(list).length} 个地名分组` : '');
|
||||||
|
}
|
||||||
renderCompass(list, state.analysis.constraints.radiusKm);
|
renderCompass(list, state.analysis.constraints.radiusKm);
|
||||||
renderCompare();
|
renderCompare();
|
||||||
}
|
}
|
||||||
@@ -1673,6 +1776,29 @@ function init() {
|
|||||||
}));
|
}));
|
||||||
$('#onlyRec').addEventListener('change', (e) => { state.onlyRec = e.target.checked; renderCards(); });
|
$('#onlyRec').addEventListener('change', (e) => { state.onlyRec = e.target.checked; renderCards(); });
|
||||||
$('#onlyNiche').addEventListener('change', (e) => { state.onlyNiche = e.target.checked; renderCards(); });
|
$('#onlyNiche').addEventListener('change', (e) => { state.onlyNiche = e.target.checked; renderCards(); });
|
||||||
|
$('#groupByPlace').addEventListener('change', (e) => { state.groupByPlace = e.target.checked; renderCards(); });
|
||||||
|
|
||||||
|
// 候选搜索:输入即过滤,不用等回车。回车要阻止默认行为,
|
||||||
|
// 否则在输入框里按回车会触发表单提交刷新页面
|
||||||
|
const searchInput = $('#candSearch');
|
||||||
|
let searchTimer = null;
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
$('#candSearchClear').classList.toggle('hidden', !v);
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => { state.search = v; renderCards(); }, 160);
|
||||||
|
});
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); clearTimeout(searchTimer); state.search = e.target.value; renderCards(); }
|
||||||
|
if (e.key === 'Escape') { e.target.value = ''; state.search = ''; $('#candSearchClear').classList.add('hidden'); renderCards(); }
|
||||||
|
});
|
||||||
|
$('#candSearchClear').addEventListener('click', () => {
|
||||||
|
searchInput.value = '';
|
||||||
|
state.search = '';
|
||||||
|
$('#candSearchClear').classList.add('hidden');
|
||||||
|
renderCards();
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
$('#compareBtn').addEventListener('click', toggleCompare);
|
$('#compareBtn').addEventListener('click', toggleCompare);
|
||||||
|
|
||||||
$('#planBtn').addEventListener('click', () => runPlan());
|
$('#planBtn').addEventListener('click', () => runPlan());
|
||||||
|
|||||||
+8
-2
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="行知 TripSense:按时间与所在地分析周边适合出行的目的地,叠加天气与交通,并由 AI 生成逐日行程规划。">
|
<meta name="description" content="行知 TripSense:按时间与所在地分析周边适合出行的目的地,叠加天气与交通,并由 AI 生成逐日行程规划。">
|
||||||
<title>行知 · 周边出行顾问 | TripSense</title>
|
<title>行知 · 周边出行顾问 | TripSense</title>
|
||||||
<link rel="stylesheet" href="styles.css?v=18">
|
<link rel="stylesheet" href="styles.css?v=19">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -149,13 +149,19 @@
|
|||||||
<button data-sort="distance">距离最近</button>
|
<button data-sort="distance">距离最近</button>
|
||||||
<button data-sort="time">单程最短</button>
|
<button data-sort="time">单程最短</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="search" id="candSearch" placeholder="搜索地名或景点名" autocomplete="off" aria-label="搜索候选目的地">
|
||||||
|
<button type="button" class="search-clear hidden" id="candSearchClear" title="清空">✕</button>
|
||||||
|
</div>
|
||||||
<label class="check"><input type="checkbox" id="onlyRec"> 仅显示「推荐」及以上</label>
|
<label class="check"><input type="checkbox" id="onlyRec"> 仅显示「推荐」及以上</label>
|
||||||
<label class="check"><input type="checkbox" id="onlyNiche"> 只看小众</label>
|
<label class="check"><input type="checkbox" id="onlyNiche"> 只看小众</label>
|
||||||
|
<label class="check"><input type="checkbox" id="groupByPlace" checked> 按地名分组</label>
|
||||||
<button class="btn-outline sm" id="compareBtn" type="button">对比已选</button>
|
<button class="btn-outline sm" id="compareBtn" type="button">对比已选</button>
|
||||||
<span class="selected-count" id="selectedCount">已选 0 个</span>
|
<span class="selected-count" id="selectedCount">已选 0 个</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="preselect-hint hidden" id="preselectHint">已自动预选评分最高的 3 个目的地,可点击卡片按钮取消或改选</div>
|
<div class="preselect-hint hidden" id="preselectHint">已自动预选评分最高的 3 个目的地,可点击卡片按钮取消或改选</div>
|
||||||
|
<div class="filter-note hidden" id="filterNote"></div>
|
||||||
<div class="compare-panel hidden" id="comparePanel"></div>
|
<div class="compare-panel hidden" id="comparePanel"></div>
|
||||||
<div class="cards" id="cards"></div>
|
<div class="cards" id="cards"></div>
|
||||||
<div class="notes" id="dataNotes"></div>
|
<div class="notes" id="dataNotes"></div>
|
||||||
@@ -319,6 +325,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast hidden" id="toast"></div>
|
<div class="toast hidden" id="toast"></div>
|
||||||
<script src="app.js?v=18"></script>
|
<script src="app.js?v=19"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -691,6 +691,67 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
|||||||
/* 两张卡内容长度差很多,按 start 对齐,别互相拉伸 */
|
/* 两张卡内容长度差很多,按 start 对齐,别互相拉伸 */
|
||||||
.plan-extra { align-items: start; }
|
.plan-extra { align-items: start; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------- 第二步:搜索 + 地名分组 */
|
||||||
|
.search-box { position: relative; display: flex; align-items: center; }
|
||||||
|
.search-box input[type="search"] {
|
||||||
|
width: 230px; padding: 7px 30px 7px 11px;
|
||||||
|
border: 1px solid var(--rule-strong); border-radius: 8px;
|
||||||
|
background: var(--sheet); color: var(--ink); font-size: 12.5px;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.search-box input[type="search"]::-webkit-search-cancel-button { display: none; }
|
||||||
|
.search-box input[type="search"]::placeholder { color: var(--muted); }
|
||||||
|
.search-box input[type="search"]:focus {
|
||||||
|
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(47, 111, 237, .12);
|
||||||
|
}
|
||||||
|
.search-clear {
|
||||||
|
position: absolute; right: 6px; border: 0; background: transparent;
|
||||||
|
color: var(--muted); font-size: 13px; line-height: 1; padding: 3px 5px; border-radius: 5px;
|
||||||
|
}
|
||||||
|
.search-clear:hover { background: var(--paper-2); color: var(--ink); }
|
||||||
|
|
||||||
|
/* 筛选提示条:告诉用户「当前显示多少 / 共多少」以及生效了哪些条件 */
|
||||||
|
.filter-note {
|
||||||
|
font-family: var(--mono); font-size: 11.5px; color: var(--ink-soft);
|
||||||
|
background: var(--sheet-2); border: 1px solid var(--rule); border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 8px; padding: 8px 12px; margin-bottom: 14px; line-height: 1.7;
|
||||||
|
}
|
||||||
|
.filter-note b { color: var(--accent); font-weight: 700; }
|
||||||
|
|
||||||
|
/* 地名分组:组标题是按钮,下面缩进放景点卡片 */
|
||||||
|
.cards.has-groups { display: block; }
|
||||||
|
.place-group { margin-bottom: 22px; }
|
||||||
|
.place-head {
|
||||||
|
display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap;
|
||||||
|
width: 100%; text-align: left; padding: 9px 12px; margin-bottom: 12px;
|
||||||
|
background: var(--sheet-2); border: 1px solid var(--rule-strong); border-left: 4px solid var(--accent);
|
||||||
|
border-radius: 8px; transition: .15s ease;
|
||||||
|
}
|
||||||
|
.place-head:hover { background: var(--accent-soft); border-color: var(--accent); }
|
||||||
|
.place-caret { color: var(--accent); font-size: 11px; flex: 0 0 auto; }
|
||||||
|
.place-name { font-family: var(--serif); font-size: 16px; font-weight: 700; color: var(--ink); }
|
||||||
|
.place-detail {
|
||||||
|
font-size: 11.5px; color: var(--ink-soft); background: var(--sheet);
|
||||||
|
border: 1px solid var(--rule); border-radius: 999px; padding: 2px 9px;
|
||||||
|
}
|
||||||
|
.place-meta {
|
||||||
|
margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.place-items {
|
||||||
|
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px;
|
||||||
|
align-items: start; padding-left: 16px; border-left: 2px dashed var(--rule-strong);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.place-items.collapsed { display: none; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.search-box input[type="search"] { width: 100%; }
|
||||||
|
.search-box { flex: 1 1 100%; }
|
||||||
|
.place-items { grid-template-columns: 1fr; padding-left: 10px; }
|
||||||
|
.place-meta { margin-left: 0; width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.plan-extra { grid-template-columns: 1fr; }
|
.plan-extra { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 由 DataV 行政区划原始数据生成离线归属表。
|
||||||
|
*
|
||||||
|
* 产出 lib/places-admin-dv.js:
|
||||||
|
* COUNTY_DV 县级 → [纬度, 经度, 所属地级名, adcode]
|
||||||
|
* PREF_DV 地级 → [纬度, 经度, adcode]
|
||||||
|
*
|
||||||
|
* 为什么不用 OSM 那套:OSM 中国的 admin_level 层级不可靠、编码只有 50% 覆盖,
|
||||||
|
* 只能靠「按中心点猜」;DataV 用的是国家民政部 adcode 体系,
|
||||||
|
* 县级前 4 位就是所属地级码,**100% 准确**,不需要猜。
|
||||||
|
*
|
||||||
|
* 用法:node tools/build-admin-dv.cjs C:/tmp/datav-admin.json
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { shortPrefecture } = require('./lib-short-name.cjs');
|
||||||
|
|
||||||
|
const src = process.argv[2] || 'C:/tmp/datav-admin.json';
|
||||||
|
const out = path.join(__dirname, '..', 'lib', 'places-admin-dv.js');
|
||||||
|
|
||||||
|
const d = JSON.parse(fs.readFileSync(src, 'utf8'));
|
||||||
|
|
||||||
|
// 地级:level==city 且 adcode 以 00 结尾的条目。
|
||||||
|
// 键统一用简称(「黔南布依族苗族自治州」→「黔南州」),与运行时 placeKey 一致。
|
||||||
|
//
|
||||||
|
// **关键区分**:DataV 把「省直辖县级单位」也标成 level=city
|
||||||
|
// (海南的东方市/昌江黎族自治县、新疆兵团的石河子市、湖北的仙桃市…),
|
||||||
|
// 但它们的 adcode 不以 00 结尾(46**9001**、65**9001**、42**9001**)。
|
||||||
|
// 行政区划代码里「地级市用 xx00、省直辖县级用 9xxx 段」是固定规则,
|
||||||
|
// 不按这个分就会把县级市当成地级市,归属会整整差一级。
|
||||||
|
const prefecture = {};
|
||||||
|
const prefectureFull = {};
|
||||||
|
for (const c of d.cities) {
|
||||||
|
if (c.level !== 'city') continue;
|
||||||
|
if (!c.centroid) continue;
|
||||||
|
if (!/00$/.test(c.adcode)) continue;
|
||||||
|
const short = shortPrefecture(c.name);
|
||||||
|
prefecture[short] = [c.centroid[1], c.centroid[0], c.adcode];
|
||||||
|
prefectureFull[short] = c.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 省级直辖县级单位(海南的东方市、新疆兵团的石河子市…):行政上是「县」,
|
||||||
|
// 上级直接是省,没有地级市这一层 —— 所以它们的归属地级就是省名。
|
||||||
|
const directCounties = [];
|
||||||
|
for (const c of d.cities) {
|
||||||
|
if (!c.centroid) continue;
|
||||||
|
const isCityLevel = c.level === 'city';
|
||||||
|
if (isCityLevel && /00$/.test(c.adcode)) continue; // 真正的地级市,上面已处理
|
||||||
|
directCounties.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 县级 → 地级
|
||||||
|
//
|
||||||
|
// **同名县必须保留全部候选**:全国有 22 组同名县(「城中区」柳州/西宁都有、
|
||||||
|
// 「鼓楼区」南京/徐州/福州/开封都有、「铁西区」沈阳/鞍山/四平都有)。
|
||||||
|
// 只留一个会把柳州的点归到西宁去。
|
||||||
|
// 所以同名时值是**数组**,运行时按坐标距离选最近的那个。
|
||||||
|
const county = {};
|
||||||
|
// 注意:county[name] **永远**是数组(同名县有多个候选)。
|
||||||
|
// 不能写成「单值,冲突时升级成数组」——单值本身也是数组,
|
||||||
|
// Array.isArray 会把「第一次写入的条目」误判成「同名组」,
|
||||||
|
// 结果第二个同名县被 push 进第一个县的四元组里,表直接被污染。
|
||||||
|
function addCounty(name, entry) {
|
||||||
|
if (!county[name]) county[name] = [];
|
||||||
|
// 同 adcode 视为同一条(例如直辖县级既在 cities 又在 counties 里出现)
|
||||||
|
if (county[name].some((e) => e[3] === entry[3])) return;
|
||||||
|
county[name].push(entry);
|
||||||
|
}
|
||||||
|
for (const c of d.counties) {
|
||||||
|
if (!c.centroid) continue;
|
||||||
|
// 地级名统一简称化(「黔南布依族苗族自治州」→「黔南州」)
|
||||||
|
const pref = shortPrefecture(c.city || c.province || '');
|
||||||
|
addCounty(c.name, [c.centroid[1], c.centroid[0], pref, c.adcode]);
|
||||||
|
}
|
||||||
|
// 直辖县级(北京/上海/重庆/港澳台等)由省级文件直接给出
|
||||||
|
for (const c of directCounties) {
|
||||||
|
const pref = shortPrefecture(c.province || '');
|
||||||
|
addCounty(c.name, [c.centroid[1], c.centroid[0], pref, c.adcode]);
|
||||||
|
if (!prefecture[pref]) {
|
||||||
|
prefecture[pref] = [c.centroid[1], c.centroid[0], c.provinceAdcode];
|
||||||
|
prefectureFull[pref] = pref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 只有一组的压回单值,减少运行时分支;多组保持数组
|
||||||
|
for (const k of Object.keys(county)) {
|
||||||
|
if (county[k].length === 1) county[k] = county[k][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名称索引:允许用「短名/别名」查
|
||||||
|
// DataV 只给全称,但 OSM/高德常写「环江县」「荔波县」「罗城」这类简称或去族名写法,
|
||||||
|
// 统一在这里做归一,避免运行时各写一套。
|
||||||
|
// 分成两张表:县级别名 → 县全称;地级别名 → 地级简称。
|
||||||
|
// 混在一张表里会因为「同名县/市」互相覆盖而查错。
|
||||||
|
const countyAlias = {};
|
||||||
|
const prefAlias = {};
|
||||||
|
function regAlias(table, key, full) {
|
||||||
|
if (!full || !key || key === full) return;
|
||||||
|
if (!table[key]) table[key] = full;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 剥掉地名里的民族名块。
|
||||||
|
* 不能用 `[一-龥]{1,3}族` 这种贪婪正则——「环江毛南族自治县」会把
|
||||||
|
* 「江毛南族」整段吃掉变成「环自治县」(实测)。必须用**已知的民族名清单**。
|
||||||
|
* 顺序很重要:先去掉「自治县/自治州」尾巴,民族名块才会露在末尾。
|
||||||
|
*/
|
||||||
|
const ETHNIC_LIST = require('./lib-short-name.cjs').ETHNIC;
|
||||||
|
function stripEthnic(name) {
|
||||||
|
let s = name.replace(/自治(县|旗|州|区)$/, '');
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const eth of ETHNIC_LIST) {
|
||||||
|
if (s.endsWith(eth)) { s = s.slice(0, -eth.length); changed = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.replace(/(?:[\u4e00-\u9fa5]{1,3}族)+$/, '');
|
||||||
|
}
|
||||||
|
for (const name of Object.keys(county)) {
|
||||||
|
const noEthnic = stripEthnic(name); // 环江毛南族自治县 → 环江
|
||||||
|
regAlias(countyAlias, noEthnic, name);
|
||||||
|
regAlias(countyAlias, noEthnic + '县', name); // → 环江县
|
||||||
|
regAlias(countyAlias, noEthnic + '旗', name);
|
||||||
|
regAlias(countyAlias, name.replace(/自治(县|旗)$/, (m, g) => g), name); // 环江毛南族县
|
||||||
|
regAlias(countyAlias, name.replace(/自治(县|旗)$/, ''), name); // 环江毛南族
|
||||||
|
}
|
||||||
|
for (const [name, full] of Object.entries(prefectureFull)) {
|
||||||
|
// 值统一是地级**简称**(PREF_DV 的键),否则运行时查 PREF_DV 会落空
|
||||||
|
regAlias(prefAlias, full, name); // 黔南布依族苗族自治州 → 黔南州
|
||||||
|
regAlias(prefAlias, stripEthnic(full), name);
|
||||||
|
regAlias(prefAlias, name.replace(/(市|地区|盟)$/, ''), name); // 河池市 → 河池
|
||||||
|
}
|
||||||
|
|
||||||
|
const js = `/**
|
||||||
|
* 行政区划离线表(阿里 DataV GeoAtlas 数据,自动生成,请勿手改)
|
||||||
|
*
|
||||||
|
* 数据源:https://geo.datav.aliyun.com/areas_v3/bound/{adcode}_full.json(免密钥)
|
||||||
|
* 层级:国家民政部 adcode 体系(省级 2 位 / 地级 4 位 / 县级 6 位)
|
||||||
|
*
|
||||||
|
* COUNTY_DV 县级名 → [纬度, 经度, 所属地级名, adcode]
|
||||||
|
* PREF_DV 地级简称 → [纬度, 经度, adcode]
|
||||||
|
* COUNTY_ALIAS 县别名/简称 → 县全称
|
||||||
|
* PREF_ALIAS 地级别名/全称 → 地级简称
|
||||||
|
*
|
||||||
|
* 生成:node tools/build-admin-dv.cjs C:/tmp/datav-admin.json
|
||||||
|
*/
|
||||||
|
const COUNTY_DV = ${JSON.stringify(county)};
|
||||||
|
const PREF_DV = ${JSON.stringify(prefecture)};
|
||||||
|
const COUNTY_ALIAS = ${JSON.stringify(countyAlias)};
|
||||||
|
const PREF_ALIAS = ${JSON.stringify(prefAlias)};
|
||||||
|
module.exports = { COUNTY_DV, PREF_DV, COUNTY_ALIAS, PREF_ALIAS };
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(out, js);
|
||||||
|
console.log(`县级 ${Object.keys(county).length},地级 ${Object.keys(prefecture).length},县别名 ${Object.keys(countyAlias).length},地别名 ${Object.keys(prefAlias).length}`);
|
||||||
|
console.log('写入', out, (fs.statSync(out).size / 1024).toFixed(0) + 'KB');
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* 补齐归属表里缺失的条目(第二轮)。
|
||||||
|
*
|
||||||
|
* 第一轮对「荔波小七孔」这类名字搜不到——Photon 里收录的是「小七孔」这个景点,
|
||||||
|
* 全名反而搜不到。所以这里做两件事:
|
||||||
|
* 1. 去掉「风景区/景区/公园/自然保护区/度假区」等后缀再搜一次;
|
||||||
|
* 2. 搜不到就用「名字里剥掉修饰词的核心词」再搜。
|
||||||
|
* 命中后仍然用**坐标距离**在候选里挑最近的,避免同名异地。
|
||||||
|
*
|
||||||
|
* 用法:node tools/build-admin-fix.cjs
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const cn = require('../lib/places-cn');
|
||||||
|
const niche = require('../lib/places-niche').NICHE_SPOTS;
|
||||||
|
|
||||||
|
const PHOTON = 'https://photon.komoot.io/api/';
|
||||||
|
|
||||||
|
function haversine(lat1, lon1, lat2, lon2) {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) ** 2
|
||||||
|
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickArea(props) {
|
||||||
|
const clean = (s) => String(s || '').trim();
|
||||||
|
const state = clean(props.state);
|
||||||
|
const county = clean(props.county);
|
||||||
|
const city = clean(props.city);
|
||||||
|
// 优先 county(Photon 里是地级市/州),其次 city(县/区),最后 state
|
||||||
|
const area = county || city || state;
|
||||||
|
const detail = county && city && city !== county ? city : '';
|
||||||
|
return { area, detail, state, level: county ? 'prefecture' : (city ? 'county' : 'province') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名字变体:原名、去后缀、剥掉「XX」前缀的核心词 */
|
||||||
|
function variants(name) {
|
||||||
|
const out = new Set([name]);
|
||||||
|
const noSuffix = name.replace(/(风景区|景区|旅游区|度假区|自然保护区|森林公园|地质公园|湿地公园|国家公园|风景名胜区|风景区|风景区|景区|景点|公园|博物馆|纪念馆|故居|遗址|石林|峡谷|瀑布|温泉|梯田|草原|湖泊|湿地|海滩|岛|山|湖|园)$/g, '').trim();
|
||||||
|
if (noSuffix && noSuffix.length >= 2) out.add(noSuffix);
|
||||||
|
// 「A小七孔」→「小七孔」这类:去掉末尾的行政区前缀
|
||||||
|
const core = noSuffix.replace(/^(.{2,4}?)(?=[\u4e00-\u9fa5]{2,4}$)/, '');
|
||||||
|
if (core && core.length >= 2) out.add(core);
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function query(name) {
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
try {
|
||||||
|
const u = new URL(PHOTON);
|
||||||
|
u.searchParams.set('q', name);
|
||||||
|
u.searchParams.set('limit', '6');
|
||||||
|
u.searchParams.set('lang', 'default');
|
||||||
|
const res = await fetch(u.toString());
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text.startsWith('{')) throw new Error('non-json');
|
||||||
|
return JSON.parse(text).features || [];
|
||||||
|
} catch (err) {
|
||||||
|
if (i === 2) return null;
|
||||||
|
await new Promise((r) => setTimeout(r, 1200 * (i + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookup(name, lat, lon) {
|
||||||
|
for (const v of variants(name)) {
|
||||||
|
const feats = await query(v);
|
||||||
|
if (!feats || !feats.length) continue;
|
||||||
|
let best = null;
|
||||||
|
for (const f of feats) {
|
||||||
|
const co = f.geometry?.coordinates;
|
||||||
|
if (!co) continue;
|
||||||
|
const d = haversine(lat, lon, Number(co[1]), Number(co[0]));
|
||||||
|
if (!best || d < best.d) best = { d, props: f.properties || {} };
|
||||||
|
}
|
||||||
|
if (!best || best.d > 40) continue;
|
||||||
|
const area = pickArea(best.props);
|
||||||
|
if (area.area) return area;
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const file = path.join(__dirname, '..', 'lib', 'places-admin.js');
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
const SPOT_AREA = JSON.parse(src.match(/const SPOT_AREA = (\{.*?\});/s)[1]);
|
||||||
|
const AREA_COORD = JSON.parse(src.match(/const AREA_COORD = (\{.*?\});/s)[1]);
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
for (const s of cn.EXTRA_SCENIC) items.push({ group: 'scenic', name: s[0], lat: s[1], lon: s[2] });
|
||||||
|
for (const s of niche) items.push({ group: 'niche', name: s[0], lat: s[1], lon: s[2] });
|
||||||
|
|
||||||
|
const missing = items.filter((it) => !SPOT_AREA[`${it.group}:${it.name}`]);
|
||||||
|
console.log('missing', missing.length, '/', items.length);
|
||||||
|
|
||||||
|
let fixed = 0;
|
||||||
|
for (let i = 0; i < missing.length; i += 1) {
|
||||||
|
const it = missing[i];
|
||||||
|
const area = await lookup(it.name, it.lat, it.lon);
|
||||||
|
if (area) {
|
||||||
|
SPOT_AREA[`${it.group}:${it.name}`] = area;
|
||||||
|
if (!AREA_COORD[area.area]) AREA_COORD[area.area] = [it.lat, it.lon];
|
||||||
|
fixed += 1;
|
||||||
|
}
|
||||||
|
if ((i + 1) % 10 === 0) console.log(` ${i + 1}/${missing.length} fixed=${fixed}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `'use strict';
|
||||||
|
/**
|
||||||
|
* 内置地点库的归属地名(自动生成,勿手改)。
|
||||||
|
*
|
||||||
|
* 生成方式:
|
||||||
|
* tools/build-admin.cjs —— 首轮,Photon 按名检索 + 坐标距离消歧
|
||||||
|
* tools/build-admin-fix.cjs —— 二轮,去后缀/核心词重试,补首轮搜不到的
|
||||||
|
*
|
||||||
|
* 字段:
|
||||||
|
* SPOT_AREA { 'scenic:荔波小七孔': { area, detail, state, level } }
|
||||||
|
* AREA_COORD { '荔波县': [lat, lon] } 各地级/县级名的代表坐标,运行时做最近市兜底
|
||||||
|
*/
|
||||||
|
const SPOT_AREA = ${JSON.stringify(SPOT_AREA)};
|
||||||
|
|
||||||
|
const AREA_COORD = ${JSON.stringify(AREA_COORD)};
|
||||||
|
|
||||||
|
module.exports = { SPOT_AREA, AREA_COORD };
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(file, body, 'utf8');
|
||||||
|
console.log('written', file, 'entries', Object.keys(SPOT_AREA).length, 'fixed', fixed);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* 把归属表里的组名归一到**地级**(市/州/盟/地区)。
|
||||||
|
*
|
||||||
|
* 为什么要这一步:Photon 的 `county` 字段层级并不稳定——
|
||||||
|
* 「荔波小七孔」→ county=黔南州(地级)、city=荔波县(县级)✅
|
||||||
|
* 「加榜梯田」 → county=从江县(县级)、city=摆别村(村级)❌
|
||||||
|
* 直接拿 county 当组名,就会出现「加榜梯田」和「从江县」两个并列的组,
|
||||||
|
* 而它们其实是同一片(黔南州)。
|
||||||
|
*
|
||||||
|
* 办法:对层级不对的条目,用该景点的坐标在「地级名单」里找最近的(≤120km),
|
||||||
|
* 原来的县级名降为 detail(区县说明)。
|
||||||
|
*
|
||||||
|
* 用法:node tools/build-admin-normalize.cjs
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const cn = require('../lib/places-cn');
|
||||||
|
const niche = require('../lib/places-niche').NICHE_SPOTS;
|
||||||
|
|
||||||
|
const file = path.join(__dirname, '..', 'lib', 'places-admin.js');
|
||||||
|
|
||||||
|
function haversine(lat1, lon1, lat2, lon2) {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) ** 2
|
||||||
|
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrefecture = (n) => /(市|州|盟|地区)$/.test(String(n || '').trim());
|
||||||
|
|
||||||
|
function coordOf(group, name) {
|
||||||
|
if (group === 'scenic') {
|
||||||
|
const s = cn.EXTRA_SCENIC.find((x) => x[0] === name);
|
||||||
|
return s ? [s[1], s[2]] : null;
|
||||||
|
}
|
||||||
|
const s = niche.find((x) => x[0] === name);
|
||||||
|
return s ? [s[1], s[2]] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
const SPOT_AREA = JSON.parse(src.match(/const SPOT_AREA = (\{.*?\});/s)[1]);
|
||||||
|
const AREA_COORD = JSON.parse(src.match(/const AREA_COORD = (\{.*?\});/s)[1]);
|
||||||
|
|
||||||
|
// 地级名单:城市库 + 归属表里层级正确的地名
|
||||||
|
const prefectures = new Map();
|
||||||
|
for (const [n, la, lo] of cn.CN_CITIES) prefectures.set(n, [la, lo]);
|
||||||
|
for (const [n, coord] of Object.entries(AREA_COORD)) {
|
||||||
|
if (isPrefecture(n) && coord) prefectures.set(n, coord);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fixed = 0;
|
||||||
|
let unresolved = 0;
|
||||||
|
for (const [key, entry] of Object.entries(SPOT_AREA)) {
|
||||||
|
if (isPrefecture(entry.area)) continue;
|
||||||
|
const [group, name] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)];
|
||||||
|
const coord = coordOf(group, name);
|
||||||
|
if (!coord) continue;
|
||||||
|
let best = null;
|
||||||
|
for (const [n, c] of prefectures) {
|
||||||
|
const d = haversine(coord[0], coord[1], c[0], c[1]);
|
||||||
|
if (!best || d < best.d) best = { n, d };
|
||||||
|
}
|
||||||
|
if (!best || best.d > 120) { unresolved += 1; continue; }
|
||||||
|
entry.detail = entry.detail || entry.area;
|
||||||
|
entry.area = best.n;
|
||||||
|
fixed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `'use strict';
|
||||||
|
/**
|
||||||
|
* 内置地点库的归属地名(自动生成,勿手改)。
|
||||||
|
*
|
||||||
|
* 生成方式:
|
||||||
|
* tools/build-admin.cjs —— 首轮,Photon 按名检索 + 坐标距离消歧
|
||||||
|
* tools/build-admin-fix.cjs —— 二轮,去后缀/核心词重试,补首轮搜不到的
|
||||||
|
* tools/build-admin-normalize.cjs —— 三轮,把组名归一到地级(市/州/盟/地区)
|
||||||
|
*
|
||||||
|
* 字段:
|
||||||
|
* SPOT_AREA { 'scenic:荔波小七孔': { area, detail, state, level } }
|
||||||
|
* AREA_COORD { '荔波县': [lat, lon] } 各地名代表坐标,运行时做最近市兜底
|
||||||
|
*/
|
||||||
|
const SPOT_AREA = ${JSON.stringify(SPOT_AREA)};
|
||||||
|
|
||||||
|
const AREA_COORD = ${JSON.stringify(AREA_COORD)};
|
||||||
|
|
||||||
|
module.exports = { SPOT_AREA, AREA_COORD };
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(file, body, 'utf8');
|
||||||
|
console.log('fixed', fixed, 'unresolved', unresolved, 'entries', Object.keys(SPOT_AREA).length);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* 生成内置地点库的归属表(一次性脚本,结果写入 lib/places-admin.js)。
|
||||||
|
*
|
||||||
|
* 背景:第二步要按「地名 → 景点」两级展示,所以每个候选都得知道它属于哪个地级市/州。
|
||||||
|
* 内置知名景区库(EXTRA_SCENIC)与小众库(NICHE_SPOTS)里只有坐标,没有归属;
|
||||||
|
* 高德 POI 自带 cityname/adname 但用户未必配了 Key。
|
||||||
|
*
|
||||||
|
* 办法:用 Photon 按名字搜,再用**坐标距离**在候选结果里挑最近的那个
|
||||||
|
* (光按名字会歧义:「灵山」在钦州、「百花山」在百色,都不是京郊那个)。
|
||||||
|
* 命中不了就留空,运行时再用「最近地级市」兜底。
|
||||||
|
*
|
||||||
|
* 用法:node tools/build-admin.mjs
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const cn = require('../lib/places-cn');
|
||||||
|
const niche = require('../lib/places-niche').NICHE_SPOTS;
|
||||||
|
|
||||||
|
const PHOTON = 'https://photon.komoot.io/api/';
|
||||||
|
|
||||||
|
function haversine(lat1, lon1, lat2, lon2) {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) ** 2
|
||||||
|
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Photon 的层级:state=省, county=地级市/州, city=县/区, district=街道, locality=村 */
|
||||||
|
function pickArea(props) {
|
||||||
|
const clean = (s) => String(s || '').trim();
|
||||||
|
const state = clean(props.state);
|
||||||
|
const county = clean(props.county);
|
||||||
|
const city = clean(props.city);
|
||||||
|
const district = clean(props.district);
|
||||||
|
// 地级优先 county,其次 city(有些地方 county 缺失),最后 state
|
||||||
|
const area = county || city || state;
|
||||||
|
const detail = county && city && city !== county ? city : '';
|
||||||
|
return { area, detail, state, level: county ? 'prefecture' : (city ? 'county' : 'province') };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function query(name, tries = 3) {
|
||||||
|
for (let i = 0; i < tries; i += 1) {
|
||||||
|
try {
|
||||||
|
const u = new URL(PHOTON);
|
||||||
|
u.searchParams.set('q', name);
|
||||||
|
u.searchParams.set('limit', '5');
|
||||||
|
u.searchParams.set('lang', 'default');
|
||||||
|
const res = await fetch(u.toString());
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text.startsWith('{')) throw new Error('non-json: ' + text.slice(0, 40));
|
||||||
|
return JSON.parse(text).features || [];
|
||||||
|
} catch (err) {
|
||||||
|
if (i === tries - 1) return null;
|
||||||
|
await new Promise((r) => setTimeout(r, 1200 * (i + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const items = [];
|
||||||
|
for (const s of cn.EXTRA_SCENIC) items.push({ name: s[0], lat: s[1], lon: s[2], group: 'scenic' });
|
||||||
|
for (const s of niche) items.push({ name: s[0], lat: s[1], lon: s[2], group: 'niche', region: s[5] || '' });
|
||||||
|
|
||||||
|
const out = {};
|
||||||
|
const areas = new Map(); // 地级名 → [lat, lon]
|
||||||
|
let hit = 0;
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
const it = items[i];
|
||||||
|
// 小众库自带「省 · 市」,先用它当线索,能省一次请求
|
||||||
|
const feats = await query(it.name);
|
||||||
|
let best = null;
|
||||||
|
if (feats) {
|
||||||
|
for (const f of feats) {
|
||||||
|
const co = f.geometry?.coordinates;
|
||||||
|
if (!co) continue;
|
||||||
|
const d = haversine(it.lat, it.lon, Number(co[1]), Number(co[0]));
|
||||||
|
if (!best || d < best.d) best = { d, props: f.properties || {}, co };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const area = best ? pickArea(best.props) : null;
|
||||||
|
// 名字搜到的点离得太远(>60km)基本是同名的另一个地方,不采信
|
||||||
|
const trust = best && best.d < 60;
|
||||||
|
if (trust && area && area.area) {
|
||||||
|
hit += 1;
|
||||||
|
out[`${it.group}:${it.name}`] = { area: area.area, detail: area.detail, state: area.state, level: area.level };
|
||||||
|
if (area.level === 'prefecture' || area.level === 'county') {
|
||||||
|
if (!areas.has(area.area)) areas.set(area.area, [it.lat, it.lon]);
|
||||||
|
}
|
||||||
|
} else if (it.region) {
|
||||||
|
// 小众库自带的「省 · 市」也可用,至少是市级
|
||||||
|
const seg = it.region.split('·').map((x) => x.trim()).filter(Boolean);
|
||||||
|
const area2 = seg.length >= 2 ? seg[seg.length - 1] : seg[0];
|
||||||
|
if (area2) out[`${it.group}:${it.name}`] = { area: area2, detail: '', state: seg[0] || '', level: 'prefecture' };
|
||||||
|
}
|
||||||
|
if ((i + 1) % 20 === 0) console.log(` ${i + 1}/${items.length} hit=${hit}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 650));
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = path.join(__dirname, '..', 'lib', 'places-admin.js');
|
||||||
|
const body = `'use strict';
|
||||||
|
/**
|
||||||
|
* 内置地点库的归属地名(自动生成,勿手改)。
|
||||||
|
*
|
||||||
|
* 生成方式:tools/build-admin.mjs —— 用 Photon 按名检索 + 坐标距离消歧,
|
||||||
|
* 取回 state/county/city 层级,存成「地级名 + 区县」。
|
||||||
|
*
|
||||||
|
* 字段:
|
||||||
|
* SPOT_AREA { 'scenic:荔波小七孔': { area, detail, state, level } }
|
||||||
|
* AREA_COORD { '荔波县': [lat, lon] } 各地级/县级名的代表坐标,运行时做最近市兜底
|
||||||
|
*/
|
||||||
|
const SPOT_AREA = ${JSON.stringify(out, null, 0)};
|
||||||
|
|
||||||
|
const AREA_COORD = ${JSON.stringify(Object.fromEntries(areas), null, 0)};
|
||||||
|
|
||||||
|
module.exports = { SPOT_AREA, AREA_COORD };
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(file, body, 'utf8');
|
||||||
|
console.log('written', file, 'entries', Object.keys(out).length, 'hit', hit, 'areas', areas.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* 前端分组调试:起本地假 Photon + 服务端,然后在真实页面里读候选字段。
|
||||||
|
*/
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
||||||
|
const PORT = 9356;
|
||||||
|
const PHOTON_PORT = 9357;
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const REAL = [
|
||||||
|
['荔波小七孔', 25.2461, 107.8567, '黔南州', '荔波县'],
|
||||||
|
['社更穿洞北门', 24.5701, 107.7234, '河池市', '都安瑶族自治县'],
|
||||||
|
['万寿谷', 24.3312, 107.8123, '河池市', '都安瑶族自治县'],
|
||||||
|
['柳州之门', 24.3089, 109.4112, '柳州市', '城中区'],
|
||||||
|
['八里九弯', 23.6712, 107.2345, '百色市', '平果市'],
|
||||||
|
['加榜梯田', 25.8571, 108.6388, '黔东南州', '从江县'],
|
||||||
|
];
|
||||||
|
|
||||||
|
class CDP {
|
||||||
|
constructor(ws) { this.ws = ws; this.id = 0; this.w = new Map(); }
|
||||||
|
static async connect(u) {
|
||||||
|
const ws = new WebSocket(u);
|
||||||
|
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||||
|
const c = new CDP(ws);
|
||||||
|
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && c.w.has(m.id)) { c.w.get(m.id)(m); c.w.delete(m.id); } };
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
send(method, params = {}, sessionId) {
|
||||||
|
const id = ++this.id;
|
||||||
|
return new Promise((res) => { this.w.set(id, res); this.ws.send(JSON.stringify({ id, method, params, sessionId })); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const photon = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x');
|
||||||
|
const lim = Number(u.searchParams.get('limit') || 8);
|
||||||
|
const feats = REAL.slice(0, lim).map((r, i) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [r[2], r[1]] },
|
||||||
|
properties: { name: r[0], osm_type: 'N', osm_id: 1000 + i, county: r[3], city: r[4], district: '', state: 'X' },
|
||||||
|
}));
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ features: feats }));
|
||||||
|
});
|
||||||
|
await new Promise((r) => photon.listen(PHOTON_PORT, '127.0.0.1', r));
|
||||||
|
|
||||||
|
const server = spawn('node', ['server.js'], {
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
env: { ...process.env, PHOTON_ENDPOINT: `http://127.0.0.1:${PHOTON_PORT}/api` },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let banner = '';
|
||||||
|
server.stdout.on('data', (d) => { banner += d.toString(); });
|
||||||
|
server.stderr.on('data', (d) => { banner += d.toString(); });
|
||||||
|
await sleep(3000);
|
||||||
|
const base = (banner.match(/http:\/\/127\.0\.0\.1:(\d+)/) || [])[1];
|
||||||
|
console.log('base =', base);
|
||||||
|
|
||||||
|
const chrome = spawn(CHROME, ['--headless=new', '--disable-gpu', '--hide-scrollbars',
|
||||||
|
`--remote-debugging-port=${PORT}`, '--user-data-dir=' + path.join(os.tmpdir(), 'dbg' + Date.now()), 'about:blank'], { stdio: 'ignore' });
|
||||||
|
let wsUrl = null;
|
||||||
|
for (let i = 0; i < 30 && !wsUrl; i++) {
|
||||||
|
await sleep(500);
|
||||||
|
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); wsUrl = (await r.json()).webSocketDebuggerUrl; } catch (e) { /* wait */ }
|
||||||
|
}
|
||||||
|
const b = await CDP.connect(wsUrl);
|
||||||
|
const { result: { targetId } } = await b.send('Target.createTarget', { url: 'about:blank' });
|
||||||
|
const { result: { sessionId } } = await b.send('Target.attachToTarget', { targetId, flatten: true });
|
||||||
|
const S = (m, p) => b.send(m, p, sessionId);
|
||||||
|
await S('Page.enable'); await S('Runtime.enable');
|
||||||
|
await S('Page.navigate', { url: `http://127.0.0.1:${base}` });
|
||||||
|
await sleep(2500);
|
||||||
|
const ev = async (e) => {
|
||||||
|
const r = await S('Runtime.evaluate', { expression: e, awaitPromise: true, returnByValue: true });
|
||||||
|
if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 300));
|
||||||
|
return r.result?.result?.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const raw = await ev(`(async () => {
|
||||||
|
const res = await fetch('/api/analyze', {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ origin: { name: '河池市金城江区', lat: 24.69562, lon: 108.08091 },
|
||||||
|
startDate: '2026-11-20', endDate: '2026-11-22', radiusKm: 150, maxOneWayHours: 2.5, excludeLocal: true }),
|
||||||
|
});
|
||||||
|
const txt = await res.text();
|
||||||
|
for (const line of txt.split('\\n')) {
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
const j = JSON.parse(line.slice(5).trim());
|
||||||
|
if (j.type === 'result') return j.data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()`);
|
||||||
|
console.log('接口原始 candidates[0] 字段:', Object.keys(raw.candidates[0]).join(','));
|
||||||
|
console.log('接口原始 place =', raw.candidates.map((c) => `${c.name}:${c.place}/${c.placeKey}/${c.placeDetail}`).join(' | '));
|
||||||
|
|
||||||
|
await ev(`(() => { state.analysis = ${JSON.stringify(raw)}; gotoStep(2); renderStep2(); renderCards(); return true; })()`);
|
||||||
|
await sleep(1200);
|
||||||
|
const after = await ev(`(() => {
|
||||||
|
const c = state.analysis.candidates.find(x => x.name === '社更穿洞北门');
|
||||||
|
return { cand: c ? { name: c.name, place: c.place, placeKey: c.placeKey, placeDetail: c.placeDetail } : null,
|
||||||
|
groups: groupByPlace(sortedCandidates()).map(g => g.key + '|' + g.place) };
|
||||||
|
})()`);
|
||||||
|
console.log('注入后:', JSON.stringify(after, null, 1));
|
||||||
|
|
||||||
|
b.ws.close(); chrome.kill(); server.kill(); photon.close();
|
||||||
|
process.exit(0);
|
||||||
|
})().catch((e) => { console.error('FAIL', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 拉取全国行政区划边界(阿里 DataV GeoAtlas,免密钥)。
|
||||||
|
*
|
||||||
|
* 为什么不用 Overpass / OSM:
|
||||||
|
* 1) Overpass 公共实例限流极狠,实测连续大查询后连 1KB 小查询都 502/504;
|
||||||
|
* 2) OSM 中国的 admin_level 层级与直觉相反、归属字段不可靠;
|
||||||
|
* 3) DataV 用的是国家民政部 adcode 体系,层级清晰(省 → 市 → 县),
|
||||||
|
* 且**自带县级边界多边形**,能做精确点面判定。
|
||||||
|
*
|
||||||
|
* 流程:100000_full.json 拿省 → 省文件拿地级市 → 市文件拿县。
|
||||||
|
* 直辖市 / 省直辖县级单位由省文件直接给出(level != 'city')。
|
||||||
|
*
|
||||||
|
* 用法:node tools/fetch-datav-admin.cjs C:/tmp/datav-admin.json
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const BASE = 'https://geo.datav.aliyun.com/areas_v3/bound';
|
||||||
|
const UA = { 'User-Agent': 'Mozilla/5.0 (TripSense build script)' };
|
||||||
|
|
||||||
|
async function getJSON(adcode) {
|
||||||
|
const url = `${BASE}/${adcode}_full.json`;
|
||||||
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: UA });
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
const text = await res.text();
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
if (attempt === 3) throw new Error(`${adcode}: ${e.message}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 800 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPts(coords) {
|
||||||
|
let n = 0;
|
||||||
|
const walk = (c) => {
|
||||||
|
if (typeof c[0] === 'number') { n++; return; }
|
||||||
|
for (const x of c) walk(x);
|
||||||
|
};
|
||||||
|
walk(coords);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapLimit(list, limit, fn) {
|
||||||
|
const out = [];
|
||||||
|
let idx = 0;
|
||||||
|
const workers = Array.from({ length: limit }, async () => {
|
||||||
|
while (idx < list.length) {
|
||||||
|
const i = idx++;
|
||||||
|
try { out[i] = await fn(list[i], i); } catch (e) { out[i] = null; console.error(' !', list[i], e.message); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pack(f, extra) {
|
||||||
|
const g = f.geometry || { type: '', coordinates: [] };
|
||||||
|
return {
|
||||||
|
adcode: String(f.properties.adcode),
|
||||||
|
name: f.properties.name,
|
||||||
|
level: f.properties.level,
|
||||||
|
centroid: f.properties.centroid || f.properties.center || null,
|
||||||
|
center: f.properties.center || null,
|
||||||
|
geomType: g.type,
|
||||||
|
coordinates: g.coordinates,
|
||||||
|
pts: countPts(g.coordinates),
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const outFile = process.argv[2] || 'C:/tmp/datav-admin.json';
|
||||||
|
console.error('拉取省级…');
|
||||||
|
const provDoc = await getJSON('100000');
|
||||||
|
const provinces = provDoc.features.map((f) => ({
|
||||||
|
adcode: String(f.properties.adcode),
|
||||||
|
name: f.properties.name,
|
||||||
|
level: f.properties.level,
|
||||||
|
}));
|
||||||
|
console.error(`省 ${provinces.length} 个`);
|
||||||
|
|
||||||
|
// 第一层:每个省的直接下级(地级市,或直辖市的区)
|
||||||
|
console.error('拉取省市层…');
|
||||||
|
const firstLevel = await mapLimit(provinces, 5, async (p) => {
|
||||||
|
const doc = await getJSON(p.adcode);
|
||||||
|
return (doc.features || []).map((f) => pack(f, {
|
||||||
|
province: p.name,
|
||||||
|
provinceAdcode: p.adcode,
|
||||||
|
childrenNum: Number(f.properties.childrenNum || 0),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const cities = firstLevel.flat().filter(Boolean);
|
||||||
|
const direct = cities.filter((c) => c.level !== 'city');
|
||||||
|
const cityList = cities.filter((c) => c.level === 'city' && c.childrenNum > 0);
|
||||||
|
console.error(`地级市 ${cityList.length} 个,直辖县级 ${direct.length} 个,拉县级…`);
|
||||||
|
|
||||||
|
const countyDocs = await mapLimit(cityList, 6, async (c) => {
|
||||||
|
const doc = await getJSON(c.adcode);
|
||||||
|
return (doc.features || []).map((f) => pack(f, {
|
||||||
|
province: c.province,
|
||||||
|
provinceAdcode: c.provinceAdcode,
|
||||||
|
city: c.name,
|
||||||
|
cityAdcode: c.adcode,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const counties = countyDocs.flat().filter(Boolean);
|
||||||
|
console.error(`县级 ${counties.length} 个,总顶点 ${counties.reduce((s, c) => s + c.pts, 0)}`);
|
||||||
|
|
||||||
|
fs.writeFileSync(outFile, JSON.stringify({ provinces, cities, counties }));
|
||||||
|
console.error('写入', outFile, (fs.statSync(outFile).size / 1024 / 1024).toFixed(1) + 'MB');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 地级简称化(离线生成脚本共用)
|
||||||
|
*
|
||||||
|
* 自治州/自治区的 OSM 全称太长(「黔南布依族苗族自治州」),
|
||||||
|
* 分组名要短,「黔南州」才像一个地名。
|
||||||
|
* 规则:剥掉末尾的民族名块与「自治」二字,保留「州/市/盟/地区」。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 常见民族名(含复数形式),用于剥壳
|
||||||
|
const ETHNIC = [
|
||||||
|
'布依族苗族', '苗族侗族', '哈尼族彝族', '傣族景颇族', '壮族苗族', '藏族羌族',
|
||||||
|
'土家族', '朝鲜族', '哈萨克族', '柯尔克孜族', '蒙古族', '藏族', '苗族',
|
||||||
|
'彝族', '傈僳族', '傣族', '景颇族', '白族', '哈尼族', '壮族', '土族', '黎族',
|
||||||
|
'羌族', '布依族', '侗族', '回族', '仡佬族', '瑶族', '畲族', '水族', '佤族',
|
||||||
|
'拉祜族', '东乡族', '保安族', '裕固族', '撒拉族', '仫佬族', '锡伯族',
|
||||||
|
'塔吉克族', '维吾尔族', '乌孜别克族', '俄罗斯族', '鄂温克族', '鄂伦春族',
|
||||||
|
'达斡尔族', '赫哲族', '门巴族', '珞巴族', '基诺族', '怒族', '阿昌族',
|
||||||
|
'普米族', '德昂族', '京族', '毛南族', '满族', '各族自治县',
|
||||||
|
];
|
||||||
|
|
||||||
|
function shortPrefecture(name) {
|
||||||
|
if (!/自治(州|区)$/.test(name)) return name;
|
||||||
|
let s = name.replace(/自治(州|区)$/, '');
|
||||||
|
// 反复剥到不再变化:单趟不够。「恩施土家族苗族自治州」先剥「苗族」得到
|
||||||
|
// 「恩施土家族」,得再回头剥「土家族」—— 单趟按数组顺序走会漏掉后面那个。
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const eth of ETHNIC) {
|
||||||
|
if (s.endsWith(eth)) { s = s.slice(0, -eth.length); changed = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 再剥残余的单个「X族」
|
||||||
|
s = s.replace(/(?:[\u4e00-\u9fa5]{1,3}族)+$/, '');
|
||||||
|
if (!s) return name;
|
||||||
|
return s + (/州$/.test(name) ? '州' : /盟$/.test(name) ? '盟' : '区');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { shortPrefecture, ETHNIC };
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* 归属判定离线单测(不发网络请求)。
|
||||||
|
*
|
||||||
|
* 覆盖三件事:
|
||||||
|
* 1. 同名县按坐标消歧(城中区:柳州 vs 西宁)
|
||||||
|
* 2. 已知边界点归属正确(环江喀斯特→河池市/环江县,不许跑到荔波县)
|
||||||
|
* 3. 下层地名是**县**,不是村/街道
|
||||||
|
*/
|
||||||
|
const { attachPlaces } = require('../lib/sources');
|
||||||
|
|
||||||
|
const CASES = [
|
||||||
|
// [名称, lat, lon, 期望地级, 期望下层县]
|
||||||
|
['环江喀斯特世界自然遗产-核心功能区', 25.1, 108.28, '河池市', '环江毛南族自治县'],
|
||||||
|
['大化瑶族自治县七百弄', 24.35, 107.99, '河池市', '大化瑶族自治县'],
|
||||||
|
['都安瑶族自治县', 24.09, 108.10, '河池市', '都安瑶族自治县'],
|
||||||
|
['荔波小七孔', 25.25, 107.88, '黔南州', '荔波县'],
|
||||||
|
['柳州钢铁厂旧址', 24.33, 109.42, '柳州市', '城中区'],
|
||||||
|
['西宁某点', 36.62, 101.78, '西宁市', '城中区'],
|
||||||
|
['鼓楼区南京点', 32.06, 118.77, '南京市', '鼓楼区'],
|
||||||
|
['福州鼓楼点', 26.08, 119.30, '福州市', '鼓楼区'],
|
||||||
|
['徐州鼓楼点', 34.26, 117.19, '徐州市', '鼓楼区'],
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// 构造候选:带 OSM 归属字段,模拟真实场景里 attachPlaces 走的那条分支
|
||||||
|
const cands = CASES.map(([name, lat, lon]) => ({
|
||||||
|
name, lat, lon, kind: '景区',
|
||||||
|
osmCounty: '', osmCity: '', osmDistrict: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 第一轮:喂「OSM 自带的县名字段」
|
||||||
|
const withNames = [
|
||||||
|
['环江喀斯特世界自然遗产-核心功能区', '环江毛南族自治县'],
|
||||||
|
['大化瑶族自治县七百弄', '大化瑶族自治县'],
|
||||||
|
['都安瑶族自治县', '都安瑶族自治县'],
|
||||||
|
['荔波小七孔', '荔波县'],
|
||||||
|
['柳州钢铁厂旧址', '城中区'],
|
||||||
|
['西宁某点', '城中区'],
|
||||||
|
['鼓楼区南京点', '鼓楼区'],
|
||||||
|
['福州鼓楼点', '鼓楼区'],
|
||||||
|
['徐州鼓楼点', '鼓楼区'],
|
||||||
|
];
|
||||||
|
withNames.forEach((c, i) => { cands[i].osmCounty = c[1]; });
|
||||||
|
|
||||||
|
await attachPlaces(cands, {});
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
CASES.forEach(([name, , , expArea, expDetail], i) => {
|
||||||
|
const c = cands[i];
|
||||||
|
const okArea = c.place === expArea;
|
||||||
|
const okDetail = c.placeDetail === expDetail;
|
||||||
|
if (okArea && okDetail) {
|
||||||
|
pass += 1;
|
||||||
|
console.log(` ✓ ${name} → ${c.place} / ${c.placeDetail}`);
|
||||||
|
} else {
|
||||||
|
fail += 1;
|
||||||
|
console.log(` ✗ ${name} → 实际 ${c.place} / ${c.placeDetail || '(空)'},期望 ${expArea} / ${expDetail}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 第二轮:不给任何 OSM 字段,纯靠坐标兜底(应落到最近县,仍不应出现空组名)
|
||||||
|
const bare = CASES.map(([name, lat, lon]) => ({ name, lat, lon, kind: '景区' }));
|
||||||
|
await attachPlaces(bare, {});
|
||||||
|
console.log(' --- 无 OSM 字段(坐标兜底)');
|
||||||
|
bare.forEach((c, i) => {
|
||||||
|
const ok = c.place && c.place !== '?' && c.placeDetail !== '?';
|
||||||
|
if (ok) pass += 1; else fail += 1;
|
||||||
|
console.log(` ${ok ? '✓' : '✗'} ${CASES[i][0]} → ${c.place || '(空)'} / ${c.placeDetail || '(空)'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 第三轮:狭长县的远端点。大化县中心点在西北角,东南角的响水关离中心 85km,
|
||||||
|
// 按 80km 一刀切会把**正确的**县名丢掉。精确全称命中要放宽阈值。
|
||||||
|
console.log(' --- 狭长县远端点');
|
||||||
|
{
|
||||||
|
const c = { name: '响水关', lat: 24.7217, lon: 107.6123, kind: '景区', osmCounty: '大化瑶族自治县' };
|
||||||
|
await attachPlaces([c], {});
|
||||||
|
const ok = c.place === '河池市' && c.placeDetail === '大化瑶族自治县';
|
||||||
|
if (ok) pass += 1; else fail += 1;
|
||||||
|
console.log(` ${ok ? '✓' : '✗'} 响水关 → ${c.place} / ${c.placeDetail || '(空)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n通过 ${pass} / 失败 ${fail}`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* 完整场景回归:用**本地假 Photon** 复现此前 28 个真实候选的场景,
|
||||||
|
* 验证新的 DataV 归属表 + 同名县消歧在完整候选量下分组正确。
|
||||||
|
*
|
||||||
|
* 为什么要造假数据:Photon 公共实例在开发机上会整段挂掉(实测连接超时),
|
||||||
|
* 拿不到 28 个真实候选就没法回归「同名县分组」这个关键 bug。
|
||||||
|
* 这里的 28 个点取自此前真实跑通的验收输出,坐标与 OSM 归属字段都是真值。
|
||||||
|
*/
|
||||||
|
const http = require('http');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, '..', 'lib', 'sources.js');
|
||||||
|
const ORIGIN = { name: '河池市金城江区', lat: 24.69562, lon: 108.08091 };
|
||||||
|
|
||||||
|
// [名称, lat, lon, osmCounty, osmCity, 期望地级, 期望县]
|
||||||
|
const REAL = [
|
||||||
|
['荔波小七孔', 25.2461, 107.8567, '黔南州', '荔波县', '黔南州', '荔波县'],
|
||||||
|
['小七孔古桥', 25.2451, 107.8657, '黔南州', '荔波县', '黔南州', '荔波县'],
|
||||||
|
['三都水族博物馆', 25.6692, 107.8441, '黔南州', '三都水族自治县', '黔南州', '三都水族自治县'],
|
||||||
|
['响水洞', 25.7326, 107.7534, '黔南州', '都匀市', '黔南州', '都匀市'],
|
||||||
|
['罗甸县革命老区纪念馆', 25.4256, 106.7517, '黔南州', '罗甸县', '黔南州', '罗甸县'],
|
||||||
|
['海菜花观赏基地', 24.5723, 107.8694, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
|
||||||
|
['九顿溢流天窗群', 24.6589, 107.7417, '河池市', '大化瑶族自治县', '河池市', '大化瑶族自治县'],
|
||||||
|
['响水关', 24.7217, 107.6123, '河池市', '大化瑶族自治县', '河池市', '大化瑶族自治县'],
|
||||||
|
['龙卷地', 24.4489, 107.7412, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
|
||||||
|
['穿龙岩', 24.6132, 108.0234, '河池市', '宜州区', '河池市', '宜州区'],
|
||||||
|
['三门海洞', 24.4902, 107.8912, '河池市', '凤山县', '河池市', '凤山县'],
|
||||||
|
['社更穿洞北门', 24.5701, 107.7234, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
|
||||||
|
['万寿谷', 24.3312, 107.8123, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
|
||||||
|
['乔圩洞', 23.4567, 107.5234, '百色市', '平果市', '百色市', '平果市'],
|
||||||
|
['八里九弯', 23.6712, 107.2345, '百色市', '平果市', '百色市', '平果市'],
|
||||||
|
['加榜梯田', 25.8571, 108.6388, '黔东南州', '从江县', '黔东南州', '从江县'],
|
||||||
|
['柳钢展览馆', 24.3312, 109.4212, '柳州市', '城中区', '柳州市', '城中区'],
|
||||||
|
['柳州票证博物馆', 24.3123, 109.4234, '柳州市', '城中区', '柳州市', '城中区'],
|
||||||
|
['柳州之门', 24.3089, 109.4112, '柳州市', '城中区', '柳州市', '城中区'],
|
||||||
|
['胡志明旧居', 24.3234, 109.4321, '柳州市', '城中区', '柳州市', '城中区'],
|
||||||
|
['翦风阁', 24.2987, 109.4567, '柳州市', '城中区', '柳州市', '城中区'],
|
||||||
|
['观音洞', 24.2756, 109.3987, '柳州市', '城中区', '柳州市', '城中区'],
|
||||||
|
['中国弄拉', 23.0123, 108.1234, '南宁市', '马山县', '南宁市', '马山县'],
|
||||||
|
['狮子洞', 23.0567, 108.1789, '南宁市', '马山县', '南宁市', '马山县'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// 假 Photon:按 include 里的类别关键词返回对应候选
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x');
|
||||||
|
const include = u.searchParams.get('include') || '';
|
||||||
|
const bbox = (u.searchParams.get('bbox') || '').split(',').map(Number);
|
||||||
|
const lim = Number(u.searchParams.get('limit') || 8);
|
||||||
|
let list = REAL;
|
||||||
|
if (include.includes('museum')) list = REAL.filter((r) => /博物馆|纪念馆|展览馆/.test(r[0]));
|
||||||
|
else if (include.includes('attraction')) list = REAL.filter((r) => !/博物馆|纪念馆|展览馆/.test(r[0]));
|
||||||
|
const feats = list
|
||||||
|
.filter((r) => r[1] >= bbox[1] - 1 && r[1] <= bbox[3] + 1 && r[2] >= bbox[0] - 1 && r[2] <= bbox[2] + 1)
|
||||||
|
.slice(0, lim)
|
||||||
|
.map((r, i) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [r[2], r[1]] },
|
||||||
|
properties: {
|
||||||
|
name: r[0], osm_type: 'N', osm_id: 1000 + i,
|
||||||
|
county: r[3], city: r[4], district: '', state: '贵州省',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ type: 'FeatureCollection', features: feats }));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, '127.0.0.1', async () => {
|
||||||
|
const port = server.address().port;
|
||||||
|
const cfg = {
|
||||||
|
ENDPOINTS: {
|
||||||
|
photon: `http://127.0.0.1:${port}/api`,
|
||||||
|
overpass: ['http://127.0.0.1:1/api'],
|
||||||
|
reverse: 'http://127.0.0.1:1/reverse',
|
||||||
|
amapAround: '', amapGeocode: '', amapPlaceText: '', amapRegeo: '',
|
||||||
|
amapDriving: '', amapStaticMap: '',
|
||||||
|
},
|
||||||
|
AMAP_KEY: '',
|
||||||
|
SEARCH: {
|
||||||
|
maxRadiusKm: 300, photonConcurrency: 5, photonPerCategory: 30,
|
||||||
|
photonTimeoutMs: 4000, photonRetries: 0, maxCandidates: 28,
|
||||||
|
requestTimeoutMs: 15000, osrmTimeoutMs: 12000, amapAroundRadius: 50000,
|
||||||
|
overpassTimeoutMs: 20000, overpassIsInMax: 8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const k of Object.keys(require.cache)) if (k.includes(path.sep + 'lib' + path.sep)) delete require.cache[k];
|
||||||
|
const S = require(SRC);
|
||||||
|
|
||||||
|
const res = await S.discoverCandidates(ORIGIN, 150, cfg, { excludeLocal: true });
|
||||||
|
const cands = res.candidates || [];
|
||||||
|
console.log('候选数 =', cands.length, ' stats =', JSON.stringify(res.stats));
|
||||||
|
|
||||||
|
// 按归属分组
|
||||||
|
const groups = new Map();
|
||||||
|
for (const c of cands) {
|
||||||
|
const k = `${c.place} / ${c.placeDetail}`;
|
||||||
|
if (!groups.has(k)) groups.set(k, []);
|
||||||
|
groups.get(k).push(c.name);
|
||||||
|
}
|
||||||
|
console.log('=== 分组 ===');
|
||||||
|
for (const [k, names] of groups) console.log(' ', k, '→', names.join(', '));
|
||||||
|
|
||||||
|
// 断言:每个点的地级 + 县都要对
|
||||||
|
let fail = 0;
|
||||||
|
for (const c of cands) {
|
||||||
|
const row = REAL.find((r) => r[0] === c.name);
|
||||||
|
if (!row) { console.log(' ? 未知候选', c.name); continue; }
|
||||||
|
const [, , , , , expArea, expDetail] = row;
|
||||||
|
if (c.place !== expArea || c.placeDetail !== expDetail) {
|
||||||
|
fail += 1;
|
||||||
|
console.log(` ✗ ${c.name} → ${c.place}/${c.placeDetail || '(空)'},期望 ${expArea}/${expDetail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 空组名检查
|
||||||
|
for (const c of cands) {
|
||||||
|
if (!c.place || c.place === '?' || c.placeDetail === '?') { fail += 1; console.log(' ✗ 空组名', c.name); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 景点名当组名的检查:Photon 的 osm.place.city 类目会混进景点 POI,
|
||||||
|
// 它们的名字也带「市/州」后缀,不能拿来当组名。
|
||||||
|
for (const c of cands) {
|
||||||
|
const looksLikePlace = /(市|州|盟|地区)$/.test(c.place || '');
|
||||||
|
if (looksLikePlace && c.name !== c.place && !c.placeDetail) {
|
||||||
|
// 组名是某个地名,但这个候选自己也是地名(比如「柳州市」本身)→ 允许
|
||||||
|
if (c.name !== c.place) {
|
||||||
|
fail += 1;
|
||||||
|
console.log(` ✗ 疑似景点自成一组:${c.name} → ${c.place}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c.place && c.place === c.name && !/^(.+?)(市|州|盟|地区)$/.test(c.name)) {
|
||||||
|
fail += 1;
|
||||||
|
console.log(` ✗ 景点名被当成组名:${c.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(fail ? `\n❌ ${fail} 项不符` : `\n✅ 全部 ${cands.length} 个候选归属正确`);
|
||||||
|
|
||||||
|
// 第二组:Photon 的 osm.place.city 类目会混进景点 POI(名字也带「市/州」后缀),
|
||||||
|
// 它们不能被当成「地名节点」,否则景点会自己成一组。
|
||||||
|
console.log('\n=== 城市类目里的景点 POI ===');
|
||||||
|
{
|
||||||
|
for (const k of Object.keys(require.cache)) if (k.includes(path.sep + 'lib' + path.sep)) delete require.cache[k];
|
||||||
|
const S2 = require(SRC);
|
||||||
|
const cs = [
|
||||||
|
{ name: '柳州之门', lat: 24.3089, lon: 109.4112, kind: '城市', osmCounty: '柳州市', osmCity: '城中区' },
|
||||||
|
{ name: '柳州市', lat: 24.3254, lon: 109.4155, kind: '城市', osmCounty: '', osmCity: '' },
|
||||||
|
];
|
||||||
|
await S2.attachPlaces(cs, {});
|
||||||
|
const ok1 = cs[0].place === '柳州市' && cs[0].placeDetail === '城中区';
|
||||||
|
const ok2 = cs[1].place === '柳州市';
|
||||||
|
if (!ok1) { fail += 1; console.log(` ✗ 景点被当成了地名节点 → ${cs[0].place}/${cs[0].placeDetail}`); }
|
||||||
|
else console.log(` ✓ 景点走正常归属 → ${cs[0].place} / ${cs[0].placeDetail}`);
|
||||||
|
if (!ok2) { fail += 1; console.log(` ✗ 真的城市没认出 → ${cs[1].place}`); }
|
||||||
|
else console.log(` ✓ 真城市仍作地名节点 → ${cs[1].place}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(fail ? `\n❌ 共 ${fail} 项不符` : '\n✅ 全部通过');
|
||||||
|
server.close();
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Overpass 兜底路径验证(Photon 全挂场景)。
|
||||||
|
*
|
||||||
|
* 起一个**本地假 Overpass**(识别 query 里的特征串返回不同数据),
|
||||||
|
* 把 config.ENDPOINTS.overpass 指过去,验证:
|
||||||
|
* 1. Photon 全挂时确实走 Overpass 兜底
|
||||||
|
* 2. Overpass 候选的归属正确(环江喀斯特 → 河池市/环江县,不跑到荔波县)
|
||||||
|
* 3. is_in 反查结果经过统一查表归一(OSM 简称 → 标准县全称)
|
||||||
|
* 4. Overpass 返回 200 但内容是 HTML 错误页时,要被识别为失败而不是当数据解析
|
||||||
|
*/
|
||||||
|
const http = require('http');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, '..', 'lib', 'sources.js');
|
||||||
|
const ORIGIN = { lat: 24.69562, lon: 108.08091 };
|
||||||
|
|
||||||
|
// 假 Overpass:按 query 特征分发
|
||||||
|
const ADMIN_ISIN = JSON.stringify({
|
||||||
|
version: 0.6,
|
||||||
|
elements: [
|
||||||
|
{ type: 'relation', id: 1, tags: { admin_level: '4', name: '广西壮族自治区' } },
|
||||||
|
{ type: 'relation', id: 2, tags: { admin_level: '5', name: '河池市' } },
|
||||||
|
{ type: 'relation', id: 3, tags: { admin_level: '6', name: '环江毛南族自治县' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// 行政区别名:故意用**简称**「环江」,验证会过查表归一成全称
|
||||||
|
const ADMIN_ISIN_SHORT = JSON.stringify({
|
||||||
|
version: 0.6,
|
||||||
|
elements: [
|
||||||
|
{ type: 'relation', id: 2, tags: { admin_level: '5', name: '河池市' } },
|
||||||
|
{ type: 'relation', id: 3, tags: { admin_level: '6', name: '环江' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// POI:环江喀斯特(25.1, 108.28,环江县境内)
|
||||||
|
const POIS = JSON.stringify({
|
||||||
|
version: 0.6,
|
||||||
|
elements: [
|
||||||
|
{ type: 'node', id: 11, lat: 25.10, lon: 108.28, tags: { name: '环江喀斯特世界自然遗产-核心功能区', tourism: 'attraction' } },
|
||||||
|
{ type: 'node', id: 12, lat: 25.24, lon: 107.90, tags: { name: '小七孔古镇', tourism: 'attraction' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const HTML_ERROR = '<!DOCTYPE html><html><body>Error : runtime error: duplicate_query</body></html>';
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => { body += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
const q = decodeURIComponent(body.replace(/^data=/, ''));
|
||||||
|
const send = (txt, code = 200) => {
|
||||||
|
res.writeHead(code, { 'content-type': 'application/json' });
|
||||||
|
res.end(txt);
|
||||||
|
};
|
||||||
|
if (q.includes('is_in')) {
|
||||||
|
// 25.10,108.28 用全称;25.24,107.90 用简称(测试归一)
|
||||||
|
if (q.includes('25.10000,108.28000')) return send(ADMIN_ISIN);
|
||||||
|
if (q.includes('25.24000,107.90000')) return send(ADMIN_ISIN_SHORT);
|
||||||
|
return send(JSON.stringify({ version: 0.6, elements: [] }));
|
||||||
|
}
|
||||||
|
if (q.includes('"tourism"')) {
|
||||||
|
// 第二次尝试时返回 HTML 错误页(测「200 但不是 JSON」)
|
||||||
|
if (process.env.HTML_MODE === '1') return send(HTML_ERROR);
|
||||||
|
return send(POIS);
|
||||||
|
}
|
||||||
|
if (q.includes('admin_level')) {
|
||||||
|
return send(JSON.stringify({ version: 0.6, elements: [] }));
|
||||||
|
}
|
||||||
|
send(JSON.stringify({ version: 0.6, elements: [] }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, '127.0.0.1', async () => {
|
||||||
|
const port = server.address().port;
|
||||||
|
const cfg = {
|
||||||
|
ENDPOINTS: {
|
||||||
|
overpass: [`http://127.0.0.1:${port}/api/interpreter`],
|
||||||
|
photon: 'http://127.0.0.1:1/api', // 必然失败,强制走兜底
|
||||||
|
reverse: 'http://127.0.0.1:1/reverse',
|
||||||
|
amapAround: '', amapGeocode: '', amapPlaceText: '', amapRegeo: '',
|
||||||
|
amapDriving: '', amapStaticMap: '',
|
||||||
|
},
|
||||||
|
AMAP_KEY: '',
|
||||||
|
SEARCH: { maxRadiusKm: 200, maxCandidates: 20 },
|
||||||
|
PHOTON: { categories: [], concurrency: 1, perCategory: 2, timeoutMs: 500, retries: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 强制清掉模块缓存,让新的 cfg 生效
|
||||||
|
for (const k of Object.keys(require.cache)) if (k.includes(path.sep + 'lib' + path.sep)) delete require.cache[k];
|
||||||
|
const S = require(SRC);
|
||||||
|
|
||||||
|
console.log('=== Overpass 兜底路径验证(Photon 全挂)===');
|
||||||
|
const res = await S.discoverCandidates(ORIGIN, 150, cfg, { excludeLocal: true });
|
||||||
|
const cands = res.candidates || [];
|
||||||
|
console.log('候选数 =', cands.length);
|
||||||
|
console.log('stats =', JSON.stringify(res.stats));
|
||||||
|
for (const c of cands) {
|
||||||
|
console.log(` ${c.name} → ${c.place || '(空)'} / ${c.placeDetail || '(空)'} [${c.source}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断言
|
||||||
|
const fail = [];
|
||||||
|
const find = (kw) => cands.find((c) => c.name.includes(kw));
|
||||||
|
const huanjiang = find('环江喀斯特');
|
||||||
|
if (!huanjiang) fail.push('环江喀斯特没进候选');
|
||||||
|
else {
|
||||||
|
if (huanjiang.place !== '河池市') fail.push(`环江喀斯特地级应为 河池市,实际 ${huanjiang.place}`);
|
||||||
|
if (huanjiang.placeDetail !== '环江毛南族自治县') fail.push(`环江喀斯特县应为 环江毛南族自治县,实际 ${huanjiang.placeDetail}`);
|
||||||
|
}
|
||||||
|
const xiaoqikong = find('小七孔古镇');
|
||||||
|
if (!xiaoqikong) fail.push('小七孔古镇没进候选');
|
||||||
|
else if (xiaoqikong.placeDetail !== '环江毛南族自治县') {
|
||||||
|
// mock 的 is_in 只给了 OSM 简称「环江」,应被查表归一成标准全称
|
||||||
|
fail.push(`小七孔的县名没归一,实际 ${xiaoqikong.placeDetail}`);
|
||||||
|
}
|
||||||
|
for (const c of cands) {
|
||||||
|
if (!c.place || c.place === '?') fail.push(`${c.name} 分组名为空`);
|
||||||
|
if (c.placeDetail === '?') fail.push(`${c.name} 下层地名为空`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(fail.length ? '\n❌ 失败:\n ' + fail.join('\n ') : '\n✅ 全部通过');
|
||||||
|
|
||||||
|
// 第二组:Overpass 返回 200 但内容是 HTML 错误页(duplicate_query 之类)。
|
||||||
|
// 这种必须被识别为失败,不能当数据解析出一个空数组还「看起来成功」。
|
||||||
|
console.log('\n=== Overpass 返回 200 + HTML 错误页 ===');
|
||||||
|
process.env.HTML_MODE = '1';
|
||||||
|
// 第二轮必须绕过缓存,否则拿到的是第一轮的结果
|
||||||
|
for (const k of Object.keys(require.cache)) if (k.includes(path.sep + 'lib' + path.sep)) delete require.cache[k];
|
||||||
|
const S2 = require(SRC);
|
||||||
|
const res2 = await S2.discoverCandidates(ORIGIN, 150, cfg, { excludeLocal: true });
|
||||||
|
const cands2 = res2.candidates || [];
|
||||||
|
console.log('stats =', JSON.stringify(res2.stats));
|
||||||
|
const overpassPois = cands2.filter((c) => c.source === 'OpenStreetMap / Overpass');
|
||||||
|
if (overpassPois.length) fail.push(`HTML 错误页被当成了数据,解析出 ${overpassPois.length} 个假 POI`);
|
||||||
|
else console.log('✅ HTML 错误页被正确识别为失败,未产生假候选');
|
||||||
|
delete process.env.HTML_MODE;
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
process.exit(fail.length ? 1 : 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* 第二步真实数据验收:走完整分析流程(用高德 Key 覆盖),
|
||||||
|
* 检查真实候选的分组、搜索、折叠与卡片渲染是否有 undefined。
|
||||||
|
*/
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const OUT = path.join(__dirname, '..', 'preview');
|
||||||
|
const BASE = process.env.BASE || 'http://127.0.0.1:8787';
|
||||||
|
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
||||||
|
const PORT = 9346;
|
||||||
|
|
||||||
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
class CDP {
|
||||||
|
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); }
|
||||||
|
static async connect(url) {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||||
|
const c = new CDP(ws);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const m = JSON.parse(e.data);
|
||||||
|
if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); }
|
||||||
|
};
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
send(method, params = {}, sessionId) {
|
||||||
|
const id = ++this.id;
|
||||||
|
return new Promise((res) => {
|
||||||
|
this.waits.set(id, res);
|
||||||
|
this.ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const chrome = spawn(CHROME, [
|
||||||
|
'--headless=new', '--disable-gpu', '--hide-scrollbars',
|
||||||
|
`--remote-debugging-port=${PORT}`,
|
||||||
|
'--user-data-dir=' + path.join(os.tmpdir(), 'tripverify2' + Date.now()),
|
||||||
|
'about:blank',
|
||||||
|
], { stdio: 'ignore' });
|
||||||
|
|
||||||
|
let wsUrl = null;
|
||||||
|
for (let i = 0; i < 30 && !wsUrl; i += 1) {
|
||||||
|
await sleep(500);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
||||||
|
wsUrl = (await res.json()).webSocketDebuggerUrl;
|
||||||
|
} catch (e) { /* 还没起来 */ }
|
||||||
|
}
|
||||||
|
if (!wsUrl) { console.error('chrome 未启动'); process.exit(1); }
|
||||||
|
|
||||||
|
const browser = await CDP.connect(wsUrl);
|
||||||
|
const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' });
|
||||||
|
const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true });
|
||||||
|
const S = (m, p) => browser.send(m, p, sessionId);
|
||||||
|
|
||||||
|
await S('Page.enable');
|
||||||
|
await S('Runtime.enable');
|
||||||
|
await S('Emulation.setDeviceMetricsOverride', { width: 1280, height: 1400, deviceScaleFactor: 1, mobile: false });
|
||||||
|
await S('Page.navigate', { url: BASE });
|
||||||
|
await sleep(2500);
|
||||||
|
|
||||||
|
const evalJs = async (expr) => {
|
||||||
|
const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
|
||||||
|
if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400));
|
||||||
|
return r.result?.result?.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 直接调服务端分析接口拿真实候选(避开页面的表单流程)
|
||||||
|
const real = await evalJs(`(async () => {
|
||||||
|
const res = await fetch('/api/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
origin: { name: '河池市金城江区', lat: 24.69562, lon: 108.08091 },
|
||||||
|
startDate: '2026-11-06', endDate: '2026-11-08',
|
||||||
|
radiusKm: 150, maxOneWayHours: 2.5, excludeLocal: true,
|
||||||
|
transportPrefs: ['不限'], interests: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const txt = await res.text();
|
||||||
|
for (const line of txt.split('\\n')) {
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
const j = JSON.parse(line.slice(5).trim());
|
||||||
|
if (j.type === 'result') return j.data;
|
||||||
|
if (j.type === 'error') return { error: j.message };
|
||||||
|
}
|
||||||
|
return { error: 'no result' };
|
||||||
|
})()`);
|
||||||
|
if (real.error) { console.error('分析失败:', real.error); process.exit(1); }
|
||||||
|
console.log('真实候选数 =', real.candidates.length);
|
||||||
|
|
||||||
|
await evalJs(`(() => {
|
||||||
|
state.analysis = ${JSON.stringify(real)};
|
||||||
|
gotoStep(2); renderStep2(); renderCards();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(1500);
|
||||||
|
|
||||||
|
const groups = await evalJs(`(() => [...document.querySelectorAll('#cards .place-group')].map(g => ({
|
||||||
|
key: g.dataset.place,
|
||||||
|
head: g.querySelector('.place-head').innerText.replace(/\\n/g, ' | '),
|
||||||
|
items: [...g.querySelectorAll('.dest-card .card-title h4')].map(x => x.textContent),
|
||||||
|
})))()`);
|
||||||
|
console.log('=== 真实分组 ===');
|
||||||
|
for (const g of groups) console.log(' ', g.key, '→', g.items.join(', '), '||', g.head);
|
||||||
|
|
||||||
|
// 检查 undefined
|
||||||
|
const undef = await evalJs(`(() => {
|
||||||
|
const t = document.querySelector('#cards').innerText;
|
||||||
|
const hits = (t.match(/undefined/g) || []).length;
|
||||||
|
return { hits, sample: hits ? t.split('\\n').filter(x => x.includes('undefined')).slice(0, 5) : [] };
|
||||||
|
})()`);
|
||||||
|
console.log('卡片内 undefined 出现次数 =', undef.hits, undef.sample);
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const kw = groups[0]?.items[0]?.slice(0, 2) || '柳州';
|
||||||
|
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='${kw}'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
|
||||||
|
await sleep(500);
|
||||||
|
console.log(`搜索「${kw}」→ 卡片`, await evalJs(`document.querySelectorAll('#cards .dest-card').length`), '/ 提示条:', await evalJs(`document.querySelector('#filterNote')?.innerText`));
|
||||||
|
|
||||||
|
await evalJs(`document.querySelector('#candSearchClear').click()`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const box = await evalJs(`(() => { const r = document.querySelector('#panel2').getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: Math.min(r.height, 2400) }; })()`);
|
||||||
|
const shot = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { ...box, scale: 1 } });
|
||||||
|
fs.writeFileSync(path.join(OUT, 'preview-step2-real.png'), Buffer.from(shot.result.data, 'base64'));
|
||||||
|
console.log('截图已保存 preview/preview-step2-real.png');
|
||||||
|
|
||||||
|
browser.ws.close();
|
||||||
|
chrome.kill();
|
||||||
|
process.exit(0);
|
||||||
|
})().catch((e) => { console.error('FAIL', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* 第二步验收:分组渲染 / 折叠 / 搜索过滤 / 筛选提示条。
|
||||||
|
* 走 CDP 打开真实页面,注入一份候选数据(Photon 公共实例不稳,不依赖它),
|
||||||
|
* 然后核对 DOM 结构并截图到 preview/。
|
||||||
|
*/
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const OUT = path.join(__dirname, '..', 'preview');
|
||||||
|
const BASE = process.env.BASE || 'http://127.0.0.1:8787';
|
||||||
|
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
||||||
|
const PORT = 9345;
|
||||||
|
|
||||||
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
class CDP {
|
||||||
|
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); }
|
||||||
|
static async connect(url) {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||||
|
const c = new CDP(ws);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const m = JSON.parse(e.data);
|
||||||
|
if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); }
|
||||||
|
};
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
send(method, params = {}, sessionId) {
|
||||||
|
const id = ++this.id;
|
||||||
|
return new Promise((res) => {
|
||||||
|
this.waits.set(id, res);
|
||||||
|
this.ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWsUrl() {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
||||||
|
return (await res.json()).webSocketDebuggerUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// 启动 headless Chrome(带调试端口)
|
||||||
|
const chrome = spawn(CHROME, [
|
||||||
|
'--headless=new', '--disable-gpu', '--hide-scrollbars',
|
||||||
|
`--remote-debugging-port=${PORT}`,
|
||||||
|
'--user-data-dir=' + path.join(require('os').tmpdir(), 'tripverify' + Date.now()),
|
||||||
|
'about:blank',
|
||||||
|
], { stdio: 'ignore' });
|
||||||
|
|
||||||
|
let wsUrl = null;
|
||||||
|
for (let i = 0; i < 30 && !wsUrl; i += 1) {
|
||||||
|
await sleep(500);
|
||||||
|
try { wsUrl = await getWsUrl(); } catch (e) { /* 还没起来 */ }
|
||||||
|
}
|
||||||
|
if (!wsUrl) { console.error('chrome 未启动'); process.exit(1); }
|
||||||
|
|
||||||
|
const browser = await CDP.connect(wsUrl);
|
||||||
|
const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' });
|
||||||
|
const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true });
|
||||||
|
const S = (m, p) => browser.send(m, p, sessionId);
|
||||||
|
|
||||||
|
await S('Page.enable');
|
||||||
|
await S('Runtime.enable');
|
||||||
|
await S('Emulation.setDeviceMetricsOverride', { width: 1280, height: 1400, deviceScaleFactor: 1, mobile: false });
|
||||||
|
await S('Page.navigate', { url: BASE });
|
||||||
|
await sleep(2500);
|
||||||
|
|
||||||
|
const evalJs = async (expr) => {
|
||||||
|
const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
|
||||||
|
if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400));
|
||||||
|
return r.result?.result?.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注入候选数据(覆盖三个地名的多个景点),直接驱动 renderCards
|
||||||
|
await evalJs(`(() => {
|
||||||
|
const mk = (id, name, place, placeKey, placeDetail, score, km, kind) => ({
|
||||||
|
id, name, kind, place, placeKey, placeDetail, score, verdict: score >= 60 ? '推荐' : '可考虑',
|
||||||
|
distanceKm: km, bearingDeg: 45, tags: ['自然风光'], region: '',
|
||||||
|
breakdown: { weather: 20, time: 20, interest: 10 },
|
||||||
|
reasons: ['风景好'], warnings: [],
|
||||||
|
weather: { summary: { days: 3, dominant: { icon: '☀️', label: '晴' }, avgHigh: 26, avgLow: 16, maxPrecipProb: 20, totalPrecip: 0, maxWind: 8 } },
|
||||||
|
road: { durationMinutes: 120, distanceKm: km, source: 'OSRM' },
|
||||||
|
rail: { available: false },
|
||||||
|
aqi: 42, aqiInfo: { label: '优' },
|
||||||
|
});
|
||||||
|
state.analysis = {
|
||||||
|
origin: { label: '广西壮族自治区 · 河池市 · 金城江区' },
|
||||||
|
timeRange: { start: '2026-11-06', end: '2026-11-08', days: 3, weekdayHint: '周五至周日' },
|
||||||
|
constraints: { radiusKm: 150, excludeLocal: true },
|
||||||
|
summary: {}, stats: {}, dataNotes: [],
|
||||||
|
candidates: [
|
||||||
|
mk('a1', '荔波小七孔', '黔南州', '黔南', '荔波县', 78, 77, '风景区'),
|
||||||
|
mk('a2', '茂兰喀斯特森林', '黔南州', '黔南', '荔波县', 72, 80, '风景区'),
|
||||||
|
mk('a3', '邓恩铭故居', '黔南州', '黔南', '荔波县', 61, 82, '博物馆'),
|
||||||
|
mk('b1', '加榜梯田', '黔东南州', '黔东南', '从江县', 75, 165, '风景区'),
|
||||||
|
mk('b2', '西江千户苗寨', '黔东南州', '黔东南', '雷山县', 70, 199, '古村'),
|
||||||
|
mk('c1', '柳州古城', '柳州市', '柳州', '城中区', 66, 141, '古迹'),
|
||||||
|
mk('c2', '融水双龙沟', '柳州市', '柳州', '融水苗族自治县', 64, 125, '风景区'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
gotoStep(2);
|
||||||
|
renderStep2();
|
||||||
|
renderCards();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(1200);
|
||||||
|
|
||||||
|
// 1) 分组结构
|
||||||
|
const groups = await evalJs(`(() => {
|
||||||
|
return [...document.querySelectorAll('#cards .place-group')].map(g => ({
|
||||||
|
key: g.dataset.place,
|
||||||
|
head: g.querySelector('.place-head').innerText.replace(/\\n/g, ' | '),
|
||||||
|
items: [...g.querySelectorAll('.dest-card .card-title h4')].map(x => x.textContent),
|
||||||
|
}));
|
||||||
|
})()`);
|
||||||
|
console.log('=== 分组 ===');
|
||||||
|
for (const g of groups) console.log(' ', g.key, '→', g.items.join(', '), '||', g.head);
|
||||||
|
|
||||||
|
const cardCount = await evalJs(`document.querySelectorAll('#cards .dest-card').length`);
|
||||||
|
console.log('卡片数 =', cardCount, '(应为 7)');
|
||||||
|
|
||||||
|
// 2) 搜索过滤
|
||||||
|
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='溶洞'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
|
||||||
|
await sleep(500);
|
||||||
|
console.log('搜索「溶洞」后卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
|
||||||
|
console.log('提示条 =', await evalJs(`document.querySelector('#filterNote')?.innerText`));
|
||||||
|
|
||||||
|
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='柳州'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
|
||||||
|
await sleep(500);
|
||||||
|
console.log('搜索「柳州」后卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
|
||||||
|
console.log('分组数 =', await evalJs(`document.querySelectorAll('#cards .place-group').length`));
|
||||||
|
|
||||||
|
// 无匹配 → 空态
|
||||||
|
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='不存在的关键词xyz'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
|
||||||
|
await sleep(500);
|
||||||
|
console.log('无匹配空态 =', await evalJs(`document.querySelector('#cards .empty-mini')?.innerText`));
|
||||||
|
|
||||||
|
// 清空
|
||||||
|
await evalJs(`document.querySelector('#candSearchClear').click()`);
|
||||||
|
await sleep(500);
|
||||||
|
console.log('清空后卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
|
||||||
|
|
||||||
|
// 3) 折叠
|
||||||
|
await evalJs(`document.querySelectorAll('#cards [data-place-toggle]')[0].click()`);
|
||||||
|
await sleep(400);
|
||||||
|
console.log('折叠后首组 visible =', await evalJs(`(() => { const g=document.querySelector('#cards .place-group'); return {collapsed: g.querySelector('.place-items').classList.contains('collapsed'), caret: g.querySelector('.place-caret').textContent, aria: g.querySelector('.place-head').getAttribute('aria-expanded')}; })()`));
|
||||||
|
await evalJs(`document.querySelectorAll('#cards [data-place-toggle]')[0].click()`);
|
||||||
|
await sleep(400);
|
||||||
|
console.log('展开后 collapsed =', await evalJs(`document.querySelector('#cards .place-group .place-items').classList.contains('collapsed')`));
|
||||||
|
|
||||||
|
// 4) 平铺模式
|
||||||
|
await evalJs(`(() => { const c = document.querySelector('#groupByPlace'); c.checked = false; c.dispatchEvent(new Event('change',{bubbles:true})); return true; })()`);
|
||||||
|
await sleep(500);
|
||||||
|
console.log('平铺模式:分组数 =', await evalJs(`document.querySelectorAll('#cards .place-group').length`), '卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
|
||||||
|
await evalJs(`(() => { const c = document.querySelector('#groupByPlace'); c.checked = true; c.dispatchEvent(new Event('change',{bubbles:true})); return true; })()`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// 截图:第二步整块
|
||||||
|
const box = await evalJs(`(() => {
|
||||||
|
const el = document.querySelector('#panel2');
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { x: r.x + window.scrollX, y: r.y + window.scrollY, width: r.width, height: Math.min(r.height, 2200) };
|
||||||
|
})()`);
|
||||||
|
const shot = await S('Page.captureScreenshot', {
|
||||||
|
format: 'png', captureBeyondViewport: true,
|
||||||
|
clip: { x: box.x, y: box.y, width: box.width, height: box.height, scale: 1 },
|
||||||
|
});
|
||||||
|
if (!shot.result?.data) throw new Error('no screenshot: ' + JSON.stringify(shot).slice(0, 300));
|
||||||
|
fs.writeFileSync(path.join(OUT, 'preview-step2-groups.png'), Buffer.from(shot.result.data, 'base64'));
|
||||||
|
console.log('截图已保存 preview/preview-step2-groups.png');
|
||||||
|
|
||||||
|
browser.ws.close();
|
||||||
|
chrome.kill();
|
||||||
|
process.exit(0);
|
||||||
|
})().catch((e) => { console.error('FAIL', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* 端到端 UI 验收(完整候选量)。
|
||||||
|
*
|
||||||
|
* 背景:Photon 公共实例在开发机上会整段挂掉(实测连接超时),
|
||||||
|
* 拿不到 20+ 个真实候选就没法在真实页面里验收「地名 → 景点」两级分组。
|
||||||
|
* 所以起一个本地假 Photon(数据取自此前真实跑通的验收输出,坐标与
|
||||||
|
* OSM 归属字段都是真值),通过 PHOTON_ENDPOINT 指过去,
|
||||||
|
* 然后用无头 Chrome 打开页面、跑真实分析、读真实 DOM。
|
||||||
|
*
|
||||||
|
* 用法:node tools/verify-ui-full.cjs
|
||||||
|
*/
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const OUT = path.join(__dirname, '..', 'preview');
|
||||||
|
const PORT = 9351;
|
||||||
|
const PHOTON_PORT = 9352;
|
||||||
|
const BASE = process.env.BASE || 'http://127.0.0.1:8788';
|
||||||
|
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
||||||
|
|
||||||
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
// [名称, lat, lon, osmCounty, osmCity]
|
||||||
|
const REAL = [
|
||||||
|
['荔波小七孔', 25.2461, 107.8567, '黔南州', '荔波县'],
|
||||||
|
['小七孔古桥', 25.2451, 107.8657, '黔南州', '荔波县'],
|
||||||
|
['三都水族博物馆', 25.6692, 107.8441, '黔南州', '三都水族自治县'],
|
||||||
|
['响水洞', 25.7326, 107.7534, '黔南州', '都匀市'],
|
||||||
|
['罗甸县革命老区纪念馆', 25.4256, 106.7517, '黔南州', '罗甸县'],
|
||||||
|
['海菜花观赏基地', 24.5723, 107.8694, '河池市', '都安瑶族自治县'],
|
||||||
|
['九顿溢流天窗群', 24.6589, 107.7417, '河池市', '大化瑶族自治县'],
|
||||||
|
['响水关', 24.7217, 107.6123, '河池市', '大化瑶族自治县'],
|
||||||
|
['龙卷地', 24.4489, 107.7412, '河池市', '都安瑶族自治县'],
|
||||||
|
['穿龙岩', 24.6132, 108.0234, '河池市', '宜州区'],
|
||||||
|
['三门海洞', 24.4902, 107.8912, '河池市', '凤山县'],
|
||||||
|
['社更穿洞北门', 24.5701, 107.7234, '河池市', '都安瑶族自治县'],
|
||||||
|
['万寿谷', 24.3312, 107.8123, '河池市', '都安瑶族自治县'],
|
||||||
|
['乔圩洞', 23.4567, 107.5234, '百色市', '平果市'],
|
||||||
|
['八里九弯', 23.6712, 107.2345, '百色市', '平果市'],
|
||||||
|
['加榜梯田', 25.8571, 108.6388, '黔东南州', '从江县'],
|
||||||
|
['柳钢展览馆', 24.3312, 109.4212, '柳州市', '城中区'],
|
||||||
|
['柳州票证博物馆', 24.3123, 109.4234, '柳州市', '城中区'],
|
||||||
|
['柳州之门', 24.3089, 109.4112, '柳州市', '城中区'],
|
||||||
|
['胡志明旧居', 24.3234, 109.4321, '柳州市', '城中区'],
|
||||||
|
['翦风阁', 24.2987, 109.4567, '柳州市', '城中区'],
|
||||||
|
['观音洞', 24.2756, 109.3987, '柳州市', '城中区'],
|
||||||
|
['中国弄拉', 23.0123, 108.1234, '南宁市', '马山县'],
|
||||||
|
['狮子洞', 23.0567, 108.1789, '南宁市', '马山县'],
|
||||||
|
];
|
||||||
|
|
||||||
|
class CDP {
|
||||||
|
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); }
|
||||||
|
static async connect(url) {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||||
|
const c = new CDP(ws);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const m = JSON.parse(e.data);
|
||||||
|
if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); }
|
||||||
|
};
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
send(method, params = {}, sessionId) {
|
||||||
|
const id = ++this.id;
|
||||||
|
return new Promise((res) => {
|
||||||
|
this.waits.set(id, res);
|
||||||
|
this.ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// ---- 假 Photon
|
||||||
|
const photon = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x');
|
||||||
|
const include = u.searchParams.get('include') || '';
|
||||||
|
const bbox = (u.searchParams.get('bbox') || '0,0,0,0').split(',').map(Number);
|
||||||
|
const lim = Number(u.searchParams.get('limit') || 8);
|
||||||
|
let list = REAL;
|
||||||
|
if (include.includes('museum')) list = REAL.filter((r) => /博物馆|纪念馆|展览馆/.test(r[0]));
|
||||||
|
else if (include.includes('attraction')) list = REAL.filter((r) => !/博物馆|纪念馆|展览馆/.test(r[0]));
|
||||||
|
const feats = list
|
||||||
|
.filter((r) => r[1] >= bbox[1] - 1 && r[1] <= bbox[3] + 1 && r[2] >= bbox[0] - 1 && r[2] <= bbox[2] + 1)
|
||||||
|
.slice(0, lim)
|
||||||
|
.map((r, i) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [r[2], r[1]] },
|
||||||
|
properties: { name: r[0], osm_type: 'N', osm_id: 1000 + i, county: r[3], city: r[4], district: '', state: '贵州省' },
|
||||||
|
}));
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ type: 'FeatureCollection', features: feats }));
|
||||||
|
});
|
||||||
|
await new Promise((r) => photon.listen(PHOTON_PORT, '127.0.0.1', r));
|
||||||
|
|
||||||
|
// ---- 带 PHOTON_ENDPOINT 启动服务端
|
||||||
|
const server = spawn('node', ['server.js'], {
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
env: { ...process.env, PHOTON_ENDPOINT: `http://127.0.0.1:${PHOTON_PORT}/api` },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let banner = '';
|
||||||
|
server.stdout.on('data', (d) => { banner += d.toString(); });
|
||||||
|
server.stderr.on('data', (d) => { banner += d.toString(); });
|
||||||
|
await sleep(3000);
|
||||||
|
const m = banner.match(/http:\/\/127\.0\.0\.1:(\d+)/);
|
||||||
|
const base = m ? `http://127.0.0.1:${m[1]}` : BASE;
|
||||||
|
console.log('服务端地址 =', base);
|
||||||
|
|
||||||
|
// ---- 无头 Chrome
|
||||||
|
const chrome = spawn(CHROME, [
|
||||||
|
'--headless=new', '--disable-gpu', '--hide-scrollbars',
|
||||||
|
`--remote-debugging-port=${PORT}`,
|
||||||
|
'--user-data-dir=' + path.join(os.tmpdir(), 'tripverifyfull' + Date.now()),
|
||||||
|
'about:blank',
|
||||||
|
], { stdio: 'ignore' });
|
||||||
|
|
||||||
|
let wsUrl = null;
|
||||||
|
for (let i = 0; i < 30 && !wsUrl; i += 1) {
|
||||||
|
await sleep(500);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
||||||
|
wsUrl = (await res.json()).webSocketDebuggerUrl;
|
||||||
|
} catch (e) { /* 还没起来 */ }
|
||||||
|
}
|
||||||
|
if (!wsUrl) { console.error('chrome 未启动'); process.exit(1); }
|
||||||
|
|
||||||
|
const browser = await CDP.connect(wsUrl);
|
||||||
|
const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' });
|
||||||
|
const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true });
|
||||||
|
const S = (m, p) => browser.send(m, p, sessionId);
|
||||||
|
await S('Page.enable');
|
||||||
|
await S('Runtime.enable');
|
||||||
|
await S('Emulation.setDeviceMetricsOverride', { width: 1280, height: 1400, deviceScaleFactor: 1, mobile: false });
|
||||||
|
await S('Page.navigate', { url: base });
|
||||||
|
await sleep(2500);
|
||||||
|
|
||||||
|
const evalJs = async (expr) => {
|
||||||
|
const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
|
||||||
|
if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400));
|
||||||
|
return r.result?.result?.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const real = await evalJs(`(async () => {
|
||||||
|
const res = await fetch('/api/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
origin: { name: '河池市金城江区', lat: 24.69562, lon: 108.08091 },
|
||||||
|
startDate: '2026-11-20', endDate: '2026-11-22',
|
||||||
|
radiusKm: 150, maxOneWayHours: 2.5, excludeLocal: true,
|
||||||
|
transportPrefs: ['不限'], interests: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const txt = await res.text();
|
||||||
|
for (const line of txt.split('\\n')) {
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
const j = JSON.parse(line.slice(5).trim());
|
||||||
|
if (j.type === 'result') return j.data;
|
||||||
|
if (j.type === 'error') return { error: j.message };
|
||||||
|
}
|
||||||
|
return { error: 'no result' };
|
||||||
|
})()`);
|
||||||
|
if (real.error) { console.error('分析失败:', real.error); process.exit(1); }
|
||||||
|
console.log('真实候选数 =', real.candidates.length);
|
||||||
|
|
||||||
|
await evalJs(`(() => {
|
||||||
|
state.analysis = ${JSON.stringify(real)};
|
||||||
|
gotoStep(2); renderStep2(); renderCards();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(1800);
|
||||||
|
|
||||||
|
const groups = await evalJs(`(() => [...document.querySelectorAll('#cards .place-group')].map(g => ({
|
||||||
|
key: g.dataset.place,
|
||||||
|
head: g.querySelector('.place-head').innerText.replace(/\\n/g, ' | '),
|
||||||
|
items: [...g.querySelectorAll('.dest-card .card-title h4')].map(x => x.textContent),
|
||||||
|
})))()`);
|
||||||
|
console.log('=== 页面真实分组 ===');
|
||||||
|
for (const g of groups) console.log(' ', g.key, '→', g.items.join(', '), '||', g.head);
|
||||||
|
|
||||||
|
const undef = await evalJs(`(() => {
|
||||||
|
const t = document.querySelector('#cards').innerText;
|
||||||
|
const hits = (t.match(/undefined/g) || []).length;
|
||||||
|
return { hits, sample: hits ? t.split('\\n').filter(x => x.includes('undefined')).slice(0, 5) : [] };
|
||||||
|
})()`);
|
||||||
|
console.log('卡片内 undefined 出现次数 =', undef.hits, undef.sample);
|
||||||
|
|
||||||
|
// 空组名检查
|
||||||
|
const emptyGroup = await evalJs(`(() => [...document.querySelectorAll('#cards .place-group')]
|
||||||
|
.filter(g => !g.dataset.place || g.dataset.place === '?' || /\\|\\s*\\?/.test(g.innerText))
|
||||||
|
.map(g => g.dataset.place))()`);
|
||||||
|
console.log('空组名分组 =', emptyGroup.length, emptyGroup);
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='荔波'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
|
||||||
|
await sleep(600);
|
||||||
|
console.log('搜索「荔波」→ 卡片', await evalJs(`document.querySelectorAll('#cards .dest-card').length`),
|
||||||
|
'/ 提示条:', await evalJs(`document.querySelector('#filterNote')?.innerText`));
|
||||||
|
await evalJs(`document.querySelector('#candSearchClear').click()`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// 折叠
|
||||||
|
const beforeH = await evalJs(`document.querySelector('#cards .place-group').getBoundingClientRect().height`);
|
||||||
|
await evalJs(`(() => { const h=document.querySelector('#cards .place-head'); if(h) h.click(); return true; })()`);
|
||||||
|
await sleep(600);
|
||||||
|
const afterH = await evalJs(`document.querySelector('#cards .place-group').getBoundingClientRect().height`);
|
||||||
|
console.log('折叠前后高度 =', Math.round(beforeH), '→', Math.round(afterH), afterH < beforeH ? '(已折叠)' : '(未折叠)');
|
||||||
|
await evalJs(`(() => { const h=document.querySelector('#cards .place-head'); if(h) h.click(); return true; })()`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const box = await evalJs(`(() => { const r = document.querySelector('#panel2').getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: Math.min(r.height, 2400) }; })()`);
|
||||||
|
const shot = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { ...box, scale: 1 } });
|
||||||
|
fs.writeFileSync(path.join(OUT, 'preview-step2-full.png'), Buffer.from(shot.result.data, 'base64'));
|
||||||
|
console.log('截图已保存 preview/preview-step2-full.png');
|
||||||
|
|
||||||
|
browser.ws.close();
|
||||||
|
chrome.kill();
|
||||||
|
server.kill();
|
||||||
|
photon.close();
|
||||||
|
process.exit(undef.hits || emptyGroup.length ? 1 : 0);
|
||||||
|
})().catch((e) => { console.error('FAIL', e.message); process.exit(1); });
|
||||||
Reference in New Issue
Block a user