'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();