用阿里 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)
138 lines
5.3 KiB
JavaScript
138 lines
5.3 KiB
JavaScript
'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();
|