Files
tripsense/tools/build-admin-fix.cjs
T
tamakyi 7ad86c6619 归属判定升级: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)
2026-11-06 02:05:24 +08:00

138 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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();