归属判定升级: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:
2026-11-06 02:05:24 +08:00
parent c5c2ffae68
commit 7ad86c6619
25 changed files with 2766 additions and 13 deletions
+156
View File
@@ -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');
+137
View File
@@ -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();
+96
View File
@@ -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();
+125
View File
@@ -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();
+113
View File
@@ -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); });
+117
View File
@@ -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');
})();
+39
View File
@@ -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 };
+86
View File
@@ -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);
})();
+160
View File
@@ -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);
});
+139
View File
@@ -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);
});
+138
View File
@@ -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); });
+178
View File
@@ -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); });
+225
View File
@@ -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); });