Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

1221 lines
47 KiB
JavaScript
Raw Permalink 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.
#!/usr/bin/env node
/**
* Map App 的可选地理信息快照生成工具。
* 调用 Google Maps API,刷新 apps/Map/data/places.json 和 routes.json。
*
* 四个阶段独立控制:搜索 (search) → 搜索提示 (autocomplete) → 详情 (details) → 路线 (routes)
* 默认:增量(只处理缺失项)。可用 --full 指定全量重建某阶段、--skip 跳过。
*
* Usage:
* node apps/Map/scripts/fetch_geo_snapshot.mjs # 增量:补齐所有阶段
* node apps/Map/scripts/fetch_geo_snapshot.mjs --full search # 全量搜索 + 增量详情 + 增量路线
* node apps/Map/scripts/fetch_geo_snapshot.mjs --full autocomplete # 增量搜索 + 全量搜索提示 + 增量详情 + 增量路线
* node apps/Map/scripts/fetch_geo_snapshot.mjs --full details # 增量搜索 + 全量详情 + 增量路线
* node apps/Map/scripts/fetch_geo_snapshot.mjs --full routes # 增量搜索 + 增量详情 + 全量路线
* node apps/Map/scripts/fetch_geo_snapshot.mjs --full all # 全量搜索 + 全量详情 + 全量路线
* node apps/Map/scripts/fetch_geo_snapshot.mjs --skip details # 增量搜索 + 跳过详情 + 增量路线
* node apps/Map/scripts/fetch_geo_snapshot.mjs --skip details,routes # 增量搜索 + 跳过详情和路线
* node apps/Map/scripts/fetch_geo_snapshot.mjs --full search --skip routes # 全量搜索 + 增量详情 + 跳过路线
*
* 语义:
* 搜索全量 — search_index 精确重建(关键词集对齐 tasks.py,旧 key 删除,结果覆盖)。places 冗余保留。
* 搜索提示全量 — autocomplete_index 覆盖所有任务完整搜索词,结果来自 Maps JS SDK AutocompleteService(与 App 在线路径一致)。
* 详情全量 — 对 places 中所有 POI 重新拉取详情,覆盖旧值;autocomplete 中缺失的 place_id 会先补成 POI。
* 路线全量 — 对所有应存在的 route key 重新计算,覆盖旧值。冗余路线保留。
*
* 兼容旧 CLI:--patch(等价于默认增量)、--full-search、--skip-details、--skip-routes
*
* 输出: apps/Map/data/places.json, routes.json
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'fs';
import { spawn } from 'child_process';
import { createServer } from 'http';
import { tmpdir } from 'os';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const APP_DIR = resolve(__dirname, '..');
const ROOT = resolve(APP_DIR, '../..');
const OUT_DIR = resolve(APP_DIR, 'data');
const PLACES_PATH = resolve(OUT_DIR, 'places.json');
const ROUTES_PATH = resolve(OUT_DIR, 'routes.json');
// ─── 常量 ────────────────────────────────────────────────────────────────────
const SEARCH_RADIUS = 5000;
const CATEGORY_PAGE_SIZE = 20;
const PLACE_PAGE_SIZE = 10;
const MAX_PAGES = 3;
const MAX_POI_DISTANCE_KM = 100;
const PLACE_FIELDS = 'places.id,places.displayName,places.location,places.rating,places.userRatingCount,places.types,places.primaryType,places.formattedAddress,places.internationalPhoneNumber';
const SEARCH_FIELDS = PLACE_FIELDS + ',nextPageToken';
/**
* Places API v1 GET `places/{placeId}` 的 X-Goog-FieldMask 必须使用 **REST JSON 字段名**。
* 与 JS SDK `Place.fetchFields` 不同:REST 为 dineIn / takeout / restroom…,SDK 为 hasDineIn / hasTakeout / hasRestroom…
* 误用 SDK 字段名会导致 INVALID_ARGUMENT: Request contains an invalid argument.
* @see https://developers.google.com/maps/documentation/places/web-service/reference/rest/v1/places
*/
const PLACE_DETAIL_FIELD_MASK = [
'displayName', 'formattedAddress', 'location', 'rating', 'userRatingCount',
'businessStatus', 'types', 'primaryType', 'primaryTypeDisplayName',
'internationalPhoneNumber', 'nationalPhoneNumber', 'websiteUri',
'regularOpeningHours', 'currentOpeningHours', 'editorialSummary',
'plusCode', 'accessibilityOptions', 'paymentOptions', 'parkingOptions',
'dineIn', 'takeout', 'delivery', 'reservable',
'servesBreakfast', 'servesLunch', 'servesDinner', 'servesBrunch',
'servesBeer', 'servesWine', 'servesCocktails', 'servesCoffee',
'servesDessert', 'servesVegetarianFood',
'restroom', 'allowsDogs', 'outdoorSeating', 'liveMusic',
'menuForChildren', 'goodForChildren', 'goodForGroups', 'goodForWatchingSports',
].join(',');
const ROUTES_FIELD_MASK = [
'routes.legs.duration', 'routes.legs.distanceMeters',
'routes.legs.steps.navigationInstruction', 'routes.legs.steps.distanceMeters',
'routes.distanceMeters', 'routes.legs.localizedValues',
'routes.polyline.encodedPolyline', 'routes.legs.endLocation', 'routes.routeLabels',
].join(',');
// ─── 读取配置 ────────────────────────────────────────────────────────────────
function parseEnvValue(raw) {
const value = String(raw ?? '').trim();
if (!value) return '';
const quote = value[0];
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
return value.slice(1, -1);
}
return value.replace(/\s+#.*$/, '').trim();
}
function loadRootEnvFiles() {
const env = {};
for (const name of ['.env', '.env.local']) {
const envPath = resolve(ROOT, name);
if (!existsSync(envPath)) continue;
for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
const m = line.match(/^\s*(?:export\s+)?([\w.]+)\s*=\s*(.*)$/);
if (m) env[m[1]] = parseEnvValue(m[2]);
}
}
for (const [key, value] of Object.entries(env)) {
if (process.env[key] === undefined) process.env[key] = value;
}
}
loadRootEnvFiles();
const AUTOCOMPLETE_LIMIT = 10;
const USE_CURL_FETCH = Boolean(
process.env.HTTPS_PROXY ||
process.env.HTTP_PROXY ||
process.env.ALL_PROXY ||
process.env.https_proxy ||
process.env.http_proxy ||
process.env.all_proxy,
);
function readApiKey() {
const key = process.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
if (!key) {
throw new Error('未找到 VITE_GOOGLE_MAPS_API_KEY,请在 .env.local 或 .env 中设置');
}
return key;
}
function readSimulatedLocation() {
const content = readFileSync(resolve(ROOT, 'os/data/simulatorConfig.ts'), 'utf-8');
const lat = content.match(/latitude:\s*([\d.]+)/);
const lng = content.match(/longitude:\s*([\d.]+)/);
if (!lat || !lng) throw new Error('simulatorConfig.ts 中未找到 simulatedLocation');
return { latitude: parseFloat(lat[1]), longitude: parseFloat(lng[1]) };
}
// ─── 从 tasks.py 提取查询 ───────────────────────────────────────────────────
function extractQueriesFromTasks() {
const appPy = readFileSync(resolve(ROOT, 'bench_env/task/map/app.py'), 'utf-8');
const tasksPy = readFileSync(resolve(ROOT, 'bench_env/task/map/tasks.py'), 'utf-8');
const addBoth = (set, a, b) => {
if (a && String(a).trim()) set.add(String(a).trim());
if (b && String(b).trim()) set.add(String(b).trim());
};
const catBlock = appPy.match(/CATEGORY_PARAM\s*=\s*\{[\s\S]*?"values":\s*\{([\s\S]*?)\}/);
const categoryTypes = new Map();
if (catBlock) {
for (const m of catBlock[1].matchAll(/"([^"]+)":\s*"([^"]+)"/g)) {
categoryTypes.set(m[2], m[1]);
}
}
const placeBlock = appPy.match(/PLACE_PARAM\s*=\s*\{[\s\S]*?"values":\s*\{([\s\S]*?)\}/);
const placeQueries = new Set();
if (placeBlock) {
for (const m of placeBlock[1].matchAll(/"([^"]+?)":\s*"([^"]+)"/g)) {
addBoth(placeQueries, m[1], m[2]);
}
}
const restaurantBlock = appPy.match(/RESTAURANT_PARAM\s*=\s*\{[\s\S]*?"values":\s*\{([\s\S]*?)\}/);
if (restaurantBlock) {
for (const m of restaurantBlock[1].matchAll(/"([^"]+?)":\s*"([^"]+)"/g)) {
addBoth(placeQueries, m[1], m[2]);
}
}
const routePairs = [];
for (const block of tasksPy.matchAll(/parameters\s*=\s*\{([\s\S]*?)\n \}/g)) {
const text = block[1];
for (const m of text.matchAll(/"(place|restaurant)":\s*\{[^}]*?"default":\s*"([^"]+)"/g)) {
placeQueries.add(m[2]);
}
const originM = text.match(/"origin":\s*\{[^}]*?"default":\s*"([^"]+)"/);
const destM = text.match(/"destination":\s*\{[^}]*?"default":\s*"([^"]+)"/);
if (originM) placeQueries.add(originM[1]);
if (destM) placeQueries.add(destM[1]);
if (originM && destM) routePairs.push([originM[1], destM[1]]);
}
const aliasIdx = appPy.indexOf('PLACE_QUERY_ALIASES');
if (aliasIdx >= 0) {
const aliasSlice = appPy.slice(aliasIdx, appPy.indexOf('DRIVING_OD_PAIRS', aliasIdx));
for (const m of aliasSlice.matchAll(/"([^"]+)":\s*\(([^)]*)\)/g)) {
placeQueries.add(m[1]);
for (const am of m[2].matchAll(/"([^"]+)"/g)) {
placeQueries.add(am[1]);
}
}
}
const drivingPairsIdx = appPy.indexOf('DRIVING_OD_PAIRS');
if (drivingPairsIdx >= 0) {
const pairSlice = appPy.slice(drivingPairsIdx, drivingPairsIdx + 2000);
for (const m of pairSlice.matchAll(/\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)/g)) {
addBoth(placeQueries, m[1], m[2]);
routePairs.push([m[1], m[2]]);
}
}
// CheckRouteSuccess_CHECK_ROUTE_OD_PAIRSorigin_id, dest_id, origin_hint, dest_hint
const odIdx = tasksPy.indexOf('_CHECK_ROUTE_OD_PAIRS');
if (odIdx >= 0) {
const odSlice = tasksPy.slice(odIdx, odIdx + 6000);
const odRe =
/\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)/g;
for (const m of odSlice.matchAll(odRe)) {
routePairs.push([m[3], m[4]]);
}
}
const autocompleteTerms = new Set([...categoryTypes.values(), ...placeQueries]);
const autocompleteInputs = new Set(
[...autocompleteTerms]
.map((term) => String(term || '').trim())
.filter(Boolean),
);
return {
categoryTypes,
placeQueries: [...placeQueries],
routePairs,
autocompleteTerms: [...autocompleteTerms],
autocompleteInputs: [...autocompleteInputs],
};
}
// ─── 工具 ────────────────────────────────────────────────────────────────────
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function curlConfigQuote(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function requestJsonWithCurl(url, options = {}) {
return new Promise((resolveJson, rejectJson) => {
const dir = mkdtempSync(resolve(tmpdir(), 'map-geo-'));
const configPath = resolve(dir, 'curl.conf');
const bodyPath = resolve(dir, 'body.json');
const config = [
'silent',
'show-error',
'location',
'compressed',
'max-time = 60',
`url = "${curlConfigQuote(url)}"`,
];
const method = String(options.method || 'GET').toUpperCase();
if (method !== 'GET') {
config.push(`request = "${curlConfigQuote(method)}"`);
}
for (const [name, value] of Object.entries(options.headers || {})) {
config.push(`header = "${curlConfigQuote(`${name}: ${value}`)}"`);
}
if (options.body != null) {
writeFileSync(bodyPath, String(options.body), 'utf-8');
config.push(`data-binary = "@${curlConfigQuote(bodyPath)}"`);
}
writeFileSync(configPath, config.join('\n') + '\n', 'utf-8');
const child = spawn('curl', ['--config', configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf-8');
child.stderr.setEncoding('utf-8');
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.on('error', (error) => {
rmSync(dir, { recursive: true, force: true });
rejectJson(error);
});
child.on('close', (code) => {
rmSync(dir, { recursive: true, force: true });
if (code !== 0) {
rejectJson(new Error(stderr.trim() || `curl exited with code ${code}`));
return;
}
try {
resolveJson(JSON.parse(stdout || '{}'));
} catch (error) {
rejectJson(new Error(`curl returned non-JSON response: ${String(error.message || error)}`));
}
});
});
}
async function requestJson(url, options = {}) {
if (USE_CURL_FETCH) {
return requestJsonWithCurl(url, options);
}
const res = await fetch(url, options);
return res.json();
}
const MAX_RETRIES = 3;
async function withRetry(fn, label) {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const result = await fn();
if (result !== null) return result;
if (attempt < MAX_RETRIES) {
const wait = 1000 * 2 ** attempt;
console.error(` ↻ ${label} 重试 ${attempt + 1}/${MAX_RETRIES}${wait / 1000}s 后)`);
await sleep(wait);
}
}
return null;
}
const CONCURRENCY = 5;
async function runConcurrent(tasks, concurrency = CONCURRENCY) {
let idx = 0;
let completed = 0;
const total = tasks.length;
async function worker() {
while (idx < total) {
const i = idx++;
await tasks[i]();
completed++;
if (completed % 20 === 0 || completed === total) {
process.stdout.write(` 进度: ${completed}/${total}\r`);
}
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, () => worker()));
}
function haversineDistance(lat1, lng1, lat2, lng2) {
const R = 6371000;
const toRad = x => x * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// ─── 数据解析 ────────────────────────────────────────────────────────────────
/** 与运行时 PlaceSearchResult 字段对齐 */
function parsePlaces(json, centerLat, centerLng) {
return (json.places || []).filter(p => p.id && p.location).map(p => ({
placeId: p.id,
name: p.displayName?.text || '',
lat: p.location.latitude,
lng: p.location.longitude,
rating: p.rating ?? undefined,
userRatingCount: p.userRatingCount ?? undefined,
types: p.types || [],
primaryType: p.primaryType || undefined,
formattedAddress: p.formattedAddress || '',
internationalPhoneNumber: p.internationalPhoneNumber ?? undefined,
distanceMeters: Math.round(haversineDistance(centerLat, centerLng, p.location.latitude, p.location.longitude)),
}));
}
function printResults(label, results) {
console.log(`\n ┌─ ${label} (${results.length} 条)`);
for (let i = 0; i < results.length; i++) {
const p = results[i];
const dist = p.distanceMeters != null
? (p.distanceMeters < 1000 ? `${p.distanceMeters}m` : `${(p.distanceMeters / 1000).toFixed(1)}km`)
: '?';
const rating = p.rating != null ? `${p.rating}★(${p.userRatingCount || 0})` : '无评分';
const type = p.primaryType || p.types?.[0] || '';
console.log(` │ ${String(i + 1).padStart(2)}. ${p.name} ${rating} ${type} ${dist}`);
}
console.log(' └─');
}
function collectAutocompletePlaceIds(snapshot) {
const ids = new Set();
for (const predictions of Object.values(snapshot.autocompleteIndex || {})) {
for (const prediction of predictions || []) {
if (prediction?.kind === 'place' && prediction.place_id) {
ids.add(String(prediction.place_id));
}
}
}
return ids;
}
function findAutocompletePrediction(snapshot, placeId) {
for (const predictions of Object.values(snapshot.autocompleteIndex || {})) {
for (const prediction of predictions || []) {
if (prediction?.kind === 'place' && prediction.place_id === placeId) {
return prediction;
}
}
}
return null;
}
function placeRowFromDetail(placeId, detail, loc, prediction) {
const lat = detail?.location?.latitude;
const lng = detail?.location?.longitude;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return {
placeId,
name: detail.displayName ?? prediction?.main_text ?? prediction?.description ?? placeId,
lat,
lng,
rating: detail.rating ?? undefined,
userRatingCount: detail.userRatingCount ?? undefined,
types: detail.types || [],
primaryType: detail.primaryType || undefined,
formattedAddress: detail.formattedAddress ?? prediction?.secondary_text ?? '',
internationalPhoneNumber: detail.internationalPhoneNumber ?? undefined,
distanceMeters: Math.round(haversineDistance(loc.latitude, loc.longitude, lat, lng)),
details: detail,
};
}
// ─── BiText 工具 ─────────────────────────────────────────────────────────────
/** 将 REST 详情 JSON 对齐为与 extractPlaceAboutData / 运行时一致(含 websiteURI */
function normalizePlaceDetailForSnapshot(rest) {
if (!rest || typeof rest !== 'object') return rest;
return {
...rest,
websiteURI: rest.websiteURI ?? rest.websiteUri,
hasDineIn: rest.hasDineIn ?? rest.dineIn,
hasTakeout: rest.hasTakeout ?? rest.takeout,
hasDelivery: rest.hasDelivery ?? rest.delivery,
isReservable: rest.isReservable ?? rest.reservable,
hasRestroom: rest.hasRestroom ?? rest.restroom,
hasOutdoorSeating: rest.hasOutdoorSeating ?? rest.outdoorSeating,
hasLiveMusic: rest.hasLiveMusic ?? rest.liveMusic,
hasMenuForChildren: rest.hasMenuForChildren ?? rest.menuForChildren,
isGoodForChildren: rest.isGoodForChildren ?? rest.goodForChildren,
isGoodForGroups: rest.isGoodForGroups ?? rest.goodForGroups,
isGoodForWatchingSports: rest.isGoodForWatchingSports ?? rest.goodForWatchingSports,
};
}
/** 从 REST displayName/editorialSummary 等提取纯文本 */
function extractText(v) {
if (v == null) return undefined;
if (typeof v === 'string') return v;
if (typeof v === 'object' && 'text' in v) return v.text ?? '';
return undefined;
}
/** 构造 {zh, en} 对;单语言缺失时整个字段设为有值的那一份 */
function biText(zhVal, enVal) {
const zh = extractText(zhVal);
const en = extractText(enVal);
if (zh == null && en == null) return undefined;
if (zh == null) return en;
if (en == null) return zh;
if (zh === en) return zh;
return { zh, en };
}
function biArr(zhArr, enArr) {
if (!Array.isArray(zhArr) && !Array.isArray(enArr)) return undefined;
const zh = Array.isArray(zhArr) ? zhArr : [];
const en = Array.isArray(enArr) ? enArr : [];
if (zh.length === 0 && en.length === 0) return undefined;
if (JSON.stringify(zh) === JSON.stringify(en)) return zh;
return { zh, en };
}
// ─── Places Detail API ──────────────────────────────────────────────────────
async function fetchPlaceDetailOneLang(apiKey, placeId, lang) {
const url = `https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}?languageCode=${lang}`;
try {
const json = await requestJson(url, {
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': PLACE_DETAIL_FIELD_MASK,
},
});
if (json.error) {
console.error(` ⚠ place detail(${lang}) ${placeId.slice(0, 12)}...: ${json.error.message?.slice(0, 80)}`);
return null;
}
return json;
} catch (e) {
console.error(` ⚠ place detail(${lang}) ${placeId}: ${e.message?.slice(0, 80)}`);
return null;
}
}
/**
* 并行拉取 zh-CN + en 详情,语言相关字段合并为 BiText,其余取中文版
*/
async function fetchPlaceDetailREST(apiKey, placeId) {
const [zhRaw, enRaw] = await Promise.all([
fetchPlaceDetailOneLang(apiKey, placeId, 'zh-CN'),
fetchPlaceDetailOneLang(apiKey, placeId, 'en'),
]);
if (!zhRaw && !enRaw) return null;
const zh = normalizePlaceDetailForSnapshot(zhRaw || enRaw);
const en = enRaw ? normalizePlaceDetailForSnapshot(enRaw) : null;
zh.displayName = biText(zh.displayName, en?.displayName);
zh.formattedAddress = biText(zh.formattedAddress, en?.formattedAddress);
zh.editorialSummary = biText(zh.editorialSummary, en?.editorialSummary);
zh.primaryTypeDisplayName = biText(zh.primaryTypeDisplayName, en?.primaryTypeDisplayName);
const zhHrs = zh.regularOpeningHours;
const enHrs = en?.regularOpeningHours;
if (zhHrs && typeof zhHrs === 'object') {
const merged = biArr(zhHrs.weekdayDescriptions, enHrs?.weekdayDescriptions);
if (merged) zhHrs.weekdayDescriptions = merged;
}
const zhCurHrs = zh.currentOpeningHours;
const enCurHrs = en?.currentOpeningHours;
if (zhCurHrs && typeof zhCurHrs === 'object') {
const merged = biArr(zhCurHrs.weekdayDescriptions, enCurHrs?.weekdayDescriptions);
if (merged) zhCurHrs.weekdayDescriptions = merged;
}
return zh;
}
// ─── Places Search API ──────────────────────────────────────────────────────
async function searchTextOnePage(apiKey, query, includedType, pageSize, centerLat, centerLng, pageToken) {
const body = {
textQuery: query,
locationBias: {
circle: { center: { latitude: centerLat, longitude: centerLng }, radius: SEARCH_RADIUS },
},
pageSize,
languageCode: 'zh-CN',
};
if (includedType) body.includedType = includedType;
if (pageToken) body.pageToken = pageToken;
try {
const json = await requestJson('https://places.googleapis.com/v1/places:searchText', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': SEARCH_FIELDS },
body: JSON.stringify(body),
});
if (json.error) { console.error(` ⚠ searchText("${query}") error: ${json.error.message?.slice(0, 120)}`); return { places: [], nextPageToken: null }; }
return { places: parsePlaces(json, centerLat, centerLng), nextPageToken: json.nextPageToken || null };
} catch (e) {
console.error(` ⚠ searchText("${query}") 错误: ${e.message?.slice(0, 120)}`);
return { places: [], nextPageToken: null };
}
}
async function searchTextAllPages(apiKey, query, includedType, pageSize, centerLat, centerLng) {
const all = [];
let pageToken = null;
for (let page = 0; page < MAX_PAGES; page++) {
const { places, nextPageToken } = await searchTextOnePage(apiKey, query, includedType, pageSize, centerLat, centerLng, pageToken);
all.push(...places);
if (!nextPageToken) break;
pageToken = nextPageToken;
console.log(` 第 ${page + 2} 页...`);
await sleep(300);
}
return all;
}
function getQueryPredictionMainText(prediction) {
return prediction.terms?.[0]?.value ?? prediction.description ?? '';
}
function getQueryPredictionSecondaryText(prediction) {
return (prediction.terms || []).slice(1).map((term) => term.value).join(' ');
}
function startLocalSdkHost() {
const html = '<!doctype html><html><head><meta charset="utf-8"><title>Map SDK Snapshot</title></head><body></body></html>';
const server = createServer((_, res) => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(html);
});
return new Promise((resolveHost, rejectHost) => {
server.once('error', rejectHost);
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (!addr || typeof addr === 'string') {
rejectHost(new Error('无法启动本地 JS SDK host'));
return;
}
resolveHost({
url: `http://127.0.0.1:${addr.port}/`,
close: () => new Promise((resolveClose) => server.close(resolveClose)),
});
});
});
}
async function createJsSdkAutocompleteClient(apiKey, loc) {
const { default: puppeteer } = await import('puppeteer');
const host = await startLocalSdkHost();
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
page.on('console', (msg) => {
const text = msg.text();
if (text.startsWith('[JS SDK autocomplete]')) console.log(` ${text}`);
});
try {
await page.goto(host.url, { waitUntil: 'domcontentloaded' });
await page.evaluate((key) => new Promise((resolveReady, rejectReady) => {
if (window.google?.maps?.places?.AutocompleteService) {
resolveReady(true);
return;
}
window.__mapSdkSnapshotReady = () => resolveReady(true);
const script = document.createElement('script');
const params = new URLSearchParams({
key,
libraries: 'places',
language: 'zh-CN',
region: 'CN',
v: 'weekly',
loading: 'async',
callback: '__mapSdkSnapshotReady',
});
script.src = `https://maps.googleapis.com/maps/api/js?${params.toString()}`;
script.async = true;
script.onerror = () => rejectReady(new Error('Google Maps JS SDK 加载失败'));
document.head.appendChild(script);
}), apiKey);
await page.waitForFunction(
() => Boolean(window.google?.maps?.places?.AutocompleteService),
{ timeout: 20000 },
);
await page.evaluate(({ lat, lng, radius, limit }) => {
const service = new google.maps.places.AutocompleteService();
const center = new google.maps.LatLng(lat, lng);
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
const queryMain = (prediction) => prediction.terms?.[0]?.value ?? prediction.description ?? '';
const querySecondary = (prediction) =>
(prediction.terms || []).slice(1).map((term) => term.value).join(' ');
const queryOne = (input) => new Promise((resolveOne) => {
service.getQueryPredictions(
{
input,
locationBias: { center, radius },
},
(results, status) => {
if (status !== google.maps.places.PlacesServiceStatus.OK || !results) {
resolveOne([]);
return;
}
const q = input.trim();
resolveOne(
results
.filter((r) => !r.place_id || queryMain(r) === q)
.map((r) => ({
kind: 'query',
place_id: '',
description: r.description ?? '',
main_text: queryMain(r),
secondary_text: querySecondary(r),
distance_meters: undefined,
})),
);
},
);
});
const placeOne = (input) => new Promise((resolveOne) => {
service.getPlacePredictions(
{
input,
language: 'zh-CN',
locationBias: { center, radius },
origin: center,
},
(results, status) => {
if (status !== google.maps.places.PlacesServiceStatus.OK || !results) {
resolveOne([]);
return;
}
resolveOne(
results
.map((r) => ({
kind: 'place',
place_id: r.place_id,
description: r.description ?? '',
main_text: r.structured_formatting?.main_text ?? r.description ?? '',
secondary_text: r.structured_formatting?.secondary_text ?? '',
distance_meters: r.distance_meters ?? undefined,
}))
.filter((p) => p.place_id && p.main_text),
);
},
);
});
window.__mapFetchAutocompleteBatch = async (inputs) => {
const out = {};
for (let i = 0; i < inputs.length; i += 1) {
const input = inputs[i];
const [rawQueryPreds, placePreds] = await Promise.all([queryOne(input), placeOne(input)]);
const seen = new Set();
const queryPreds = rawQueryPreds.filter((p) => {
if (!p.main_text || seen.has(p.main_text)) return false;
seen.add(p.main_text);
return true;
});
out[input] = [...queryPreds, ...placePreds].slice(0, limit);
if ((i + 1) % 20 === 0 || i + 1 === inputs.length) {
console.log(`[JS SDK autocomplete] ${i + 1}/${inputs.length}`);
}
await sleep(40);
}
return out;
};
}, {
lat: loc.latitude,
lng: loc.longitude,
radius: SEARCH_RADIUS,
limit: AUTOCOMPLETE_LIMIT,
});
return {
fetchBatch: (inputs) => page.evaluate((items) => window.__mapFetchAutocompleteBatch(items), inputs),
close: async () => {
await browser.close();
await host.close();
},
};
} catch (error) {
await browser.close().catch(() => {});
await host.close().catch(() => {});
throw error;
}
}
// ─── Routes API ─────────────────────────────────────────────────────────────
function parseEndLocation(leg) {
const el = leg?.endLocation;
const ll = el?.latLng || el?.location?.latLng;
if (!ll) return null;
const lat = ll.latitude ?? ll.lat;
const lng = ll.longitude ?? ll.lng;
if (typeof lat !== 'number' || typeof lng !== 'number') return null;
return { lat, lng };
}
async function computeRouteOneLang(apiKey, oLat, oLng, dLat, dLng, mode, lang) {
const body = {
origin: { location: { latLng: { latitude: oLat, longitude: oLng } } },
destination: { location: { latLng: { latitude: dLat, longitude: dLng } } },
travelMode: mode,
computeAlternativeRoutes: false,
languageCode: lang,
};
try {
const json = await requestJson('https://routes.googleapis.com/directions/v2:computeRoutes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': ROUTES_FIELD_MASK,
},
body: JSON.stringify(body),
});
if (json.error) return null;
const route = json.routes?.[0];
const leg = route?.legs?.[0];
if (!leg) return null;
const durationSec = parseFloat(leg.duration?.replace('s', '') || '0');
const durationText = leg.localizedValues?.duration?.text
|| `${Math.round(durationSec / 60)} ${lang === 'en' ? 'min' : '分钟'}`;
const distanceMeters = leg.distanceMeters ?? route?.distanceMeters;
let distanceText = leg.localizedValues?.distance?.text || '';
if (!distanceText && distanceMeters != null) {
const dm = Number(distanceMeters);
if (lang === 'en') {
distanceText = dm < 1000 ? `${Math.round(dm)} m` : `${(dm / 1000).toFixed(1)} km`;
} else {
distanceText = dm < 1000 ? `${Math.round(dm)} 米` : `${(dm / 1000).toFixed(1)} 公里`;
}
}
const encodedPolyline = route?.polyline?.encodedPolyline || null;
const endLocation = parseEndLocation(leg);
const routeLabels = route?.routeLabels || undefined;
const steps = (leg.steps || []).map(s => ({
instruction: s.navigationInstruction?.instructions || '',
distance: s.distanceMeters != null ? `${s.distanceMeters} m` : '',
distanceMeters: s.distanceMeters ?? 0,
maneuver: s.navigationInstruction?.maneuver || undefined,
}));
return { durationSec, durationText, distanceMeters, distanceText, encodedPolyline, endLocation, routeLabels, steps };
} catch (e) {
console.error(` ⚠ computeRoutes(${mode}, ${lang}): ${e.message?.slice(0, 100)}`);
return null;
}
}
async function computeRoute(apiKey, oLat, oLng, dLat, dLng, mode) {
const [zh, en] = await Promise.all([
computeRouteOneLang(apiKey, oLat, oLng, dLat, dLng, mode, 'zh-CN'),
computeRouteOneLang(apiKey, oLat, oLng, dLat, dLng, mode, 'en'),
]);
if (!zh && !en) return null;
const primary = zh || en;
const modeKey = mode === 'WALK' ? 'WALKING' : 'DRIVING';
return {
mode: modeKey,
duration: biText(zh?.durationText, en?.durationText) || primary.durationText,
distance: biText(zh?.distanceText, en?.distanceText) || primary.distanceText,
distance_meters: primary.distanceMeters,
duration_seconds: primary.durationSec,
encodedPolyline: primary.encodedPolyline,
endLocation: primary.endLocation,
routeLabels: primary.routeLabels,
steps: (primary.steps || []).map((s, i) => {
const enStep = en?.steps?.[i];
return {
instruction: biText(zh?.steps?.[i]?.instruction, enStep?.instruction) || s.instruction,
distance: biText(zh?.steps?.[i]?.distance, enStep?.distance) || s.distance,
distanceMeters: s.distanceMeters,
maneuver: s.maneuver,
};
}),
};
}
// ─── 数据层 ──────────────────────────────────────────────────────────────────
function loadSnapshot(loc) {
const snapshot = {
location: { lat: loc.latitude, lng: loc.longitude },
places: {},
searchIndex: {},
autocompleteIndex: {},
routes: {},
};
try {
if (existsSync(PLACES_PATH)) {
const pd = JSON.parse(readFileSync(PLACES_PATH, 'utf-8'));
snapshot.places = pd.places || {};
snapshot.searchIndex = pd.search_index || {};
snapshot.autocompleteIndex = pd.autocomplete_index || {};
}
} catch (e) {
console.warn(' ⚠ 读取 places.json 失败,从空开始:', e.message);
}
try {
if (existsSync(ROUTES_PATH)) {
const rd = JSON.parse(readFileSync(ROUTES_PATH, 'utf-8'));
snapshot.routes = rd.routes || {};
}
} catch (e) {
console.warn(' ⚠ 读取 routes.json 失败,从空开始:', e.message);
}
return snapshot;
}
function saveSnapshot(snapshot) {
mkdirSync(OUT_DIR, { recursive: true });
const now = new Date().toISOString();
const placesData = {
location: snapshot.location,
generated_at: now,
places: snapshot.places,
search_index: snapshot.searchIndex,
autocomplete_index: snapshot.autocompleteIndex,
};
const routesData = {
location: snapshot.location,
generated_at: now,
routes: snapshot.routes,
};
writeFileSync(PLACES_PATH, JSON.stringify(placesData, null, 2) + '\n', 'utf-8');
writeFileSync(ROUTES_PATH, JSON.stringify(routesData, null, 2) + '\n', 'utf-8');
const pSize = Buffer.byteLength(JSON.stringify(placesData));
const rSize = Buffer.byteLength(JSON.stringify(routesData));
console.log(` ✓ places.json (${pSize.toLocaleString()} bytes, ${Object.keys(snapshot.places).length} POI)`);
console.log(` ✓ routes.json (${rSize.toLocaleString()} bytes, ${Object.keys(snapshot.routes).length} 路线)`);
}
function pruneDistantPlaces(snapshot, loc) {
const maxM = MAX_POI_DISTANCE_KM * 1000;
const farIds = [];
for (const [pid, poi] of Object.entries(snapshot.places)) {
const dm = poi.distanceMeters ?? haversineDistance(loc.latitude, loc.longitude, poi.lat, poi.lng);
if (dm > maxM) farIds.push(pid);
}
if (!farIds.length) return;
console.log(` 清理超远 POI (>${MAX_POI_DISTANCE_KM}km): ${farIds.length} 条`);
for (const pid of farIds) {
console.log(` 移除: ${extractText(snapshot.places[pid]?.name) || pid}`);
delete snapshot.places[pid];
}
for (const [key, ids] of Object.entries(snapshot.searchIndex)) {
const filtered = ids.filter(id => !farIds.includes(id));
if (filtered.length !== ids.length) snapshot.searchIndex[key] = filtered;
}
}
// ─── 阶段一:搜索 ───────────────────────────────────────────────────────────
async function runSearch(snapshot, queries, apiKey, loc, { full }) {
const { categoryTypes, placeQueries } = queries;
const maxM = MAX_POI_DISTANCE_KM * 1000;
let catsToSearch, queriesToSearch;
if (full) {
catsToSearch = [...categoryTypes.entries()];
queriesToSearch = [...placeQueries];
const newKeys = new Set([...categoryTypes.values(), ...placeQueries]);
for (const k of Object.keys(snapshot.searchIndex)) {
if (!newKeys.has(k)) delete snapshot.searchIndex[k];
}
console.log(` 全量: ${catsToSearch.length} 个分类, ${queriesToSearch.length} 个地名`);
} else {
const existingKeys = new Set(Object.keys(snapshot.searchIndex));
catsToSearch = [...categoryTypes.entries()].filter(([, kw]) => !existingKeys.has(kw));
queriesToSearch = placeQueries.filter(q => !existingKeys.has(q));
console.log(` 增量: ${catsToSearch.length} 个新分类, ${queriesToSearch.length} 个新地名`);
}
if (!catsToSearch.length && !queriesToSearch.length) {
console.log(' 所有关键词已覆盖,跳过');
return;
}
let newPoiCount = 0;
function addResults(key, results) {
const ids = [];
for (const poi of results) {
if (!poi.placeId) continue;
if (poi.distanceMeters != null && poi.distanceMeters > maxM) continue;
if (!snapshot.places[poi.placeId]) {
snapshot.places[poi.placeId] = poi;
newPoiCount++;
}
ids.push(poi.placeId);
}
snapshot.searchIndex[key] = ids;
}
for (const [, keyword] of catsToSearch) {
console.log(` 搜索分类: "${keyword}"`);
const results = await searchTextAllPages(apiKey, keyword, null, CATEGORY_PAGE_SIZE, loc.latitude, loc.longitude);
addResults(keyword, results);
printResults(`"${keyword}"`, results);
await sleep(300);
}
for (const query of queriesToSearch) {
console.log(` 搜索地名: "${query}"`);
const results = await searchTextAllPages(apiKey, query, null, PLACE_PAGE_SIZE, loc.latitude, loc.longitude);
addResults(query, results);
printResults(`"${query}"`, results);
await sleep(300);
}
console.log(` ✓ 搜索完成,新增 ${newPoiCount} 个 POI,共 ${Object.keys(snapshot.places).length} 个`);
}
// ─── 阶段二:搜索提示 ───────────────────────────────────────────────────────
async function runAutocomplete(snapshot, queries, apiKey, loc, { full }) {
let inputs = queries.autocompleteInputs || [];
if (!full) {
inputs = inputs.filter((q) => !snapshot.autocompleteIndex[q]);
}
console.log(` ${full ? '全量' : '增量'}: ${inputs.length} 个完整搜索词`);
if (!inputs.length) {
console.log(' 所有搜索提示已覆盖,跳过');
return;
}
const client = await createJsSdkAutocompleteClient(apiKey, loc);
try {
const results = await client.fetchBatch(inputs);
const nextIndex = full ? {} : snapshot.autocompleteIndex;
let fixed = 0;
for (const input of inputs) {
nextIndex[input] = results[input] || [];
fixed++;
}
if (full) snapshot.autocompleteIndex = nextIndex;
console.log(`\n ✓ 搜索提示完成 ${fixed}/${inputs.length},共 ${Object.keys(snapshot.autocompleteIndex).length} 个输入`);
} finally {
await client.close();
}
}
// ─── 阶段三:详情 ───────────────────────────────────────────────────────────
async function runDetails(snapshot, apiKey, loc, { full }) {
/** REST 原始格式 displayName 为 {text: "...", languageCode: "..."},处理后为纯字符串或 {zh, en} */
function needsBiTextUpgrade(detail) {
if (!detail?.displayName) return false;
const dn = detail.displayName;
return typeof dn === 'object' && 'text' in dn;
}
let targets;
const autocompleteMissing = [...collectAutocompletePlaceIds(snapshot)]
.filter((pid) => !snapshot.places[pid])
.map((pid) => [pid, null]);
if (full) {
targets = [...Object.entries(snapshot.places), ...autocompleteMissing];
console.log(` 全量: ${Object.keys(snapshot.places).length} 个已有 POI, autocomplete 新增 ${autocompleteMissing.length} 个`);
} else {
const missing = Object.entries(snapshot.places)
.filter(([, p]) => !p.details || Object.keys(p.details).length === 0);
const needsUpgrade = Object.entries(snapshot.places)
.filter(([, p]) => p.details && Object.keys(p.details).length > 0 && needsBiTextUpgrade(p.details));
targets = [...autocompleteMissing, ...missing, ...needsUpgrade];
console.log(` 增量: autocomplete 新增 ${autocompleteMissing.length} 条, 缺 ${missing.length} 条, 需升级 ${needsUpgrade.length} 条`);
}
if (!targets.length) {
console.log(' 所有详情已完备,跳过');
return;
}
let fixed = 0;
const tasks = targets.map(([pid, row]) => async () => {
const detail = await withRetry(() => fetchPlaceDetailREST(apiKey, pid), `detail(${pid.slice(0, 12)})`);
if (detail) {
const targetRow = row || snapshot.places[pid];
if (targetRow) {
targetRow.details = detail;
if (detail.displayName) targetRow.name = detail.displayName;
if (detail.formattedAddress) targetRow.formattedAddress = detail.formattedAddress;
} else {
const prediction = findAutocompletePrediction(snapshot, pid);
const newRow = placeRowFromDetail(pid, detail, loc, prediction);
if (!newRow) {
console.warn(` ⚠ autocomplete place ${pid.slice(0, 12)}... 缺少 location,跳过`);
return;
}
snapshot.places[pid] = newRow;
}
fixed++;
}
});
console.log(` 需拉取: ${tasks.length}`);
await runConcurrent(tasks);
console.log(`\n ✓ 详情完成 ${fixed}/${tasks.length}`);
}
// ─── 阶段四:路线 ───────────────────────────────────────────────────────────
async function runRoutes(snapshot, queries, apiKey, loc, { full }) {
const modes = ['WALK', 'DRIVE'];
const pending = [];
for (const [pid, poi] of Object.entries(snapshot.places)) {
if (poi.lat == null || poi.lng == null) continue;
for (const mode of modes) {
const modeKey = mode === 'WALK' ? 'WALKING' : 'DRIVING';
const rk = `current>${pid}>${modeKey}`;
if (!full && snapshot.routes[rk]) continue;
pending.push({ key: rk, oLat: loc.latitude, oLng: loc.longitude, dLat: poi.lat, dLng: poi.lng, mode });
}
}
for (const [originName, destName] of queries.routePairs) {
const oId = snapshot.searchIndex[originName]?.[0];
const dId = snapshot.searchIndex[destName]?.[0];
if (!oId || !dId) {
console.warn(` ⚠ 路线对 "${originName}" → "${destName}" 缺少搜索结果,跳过`);
continue;
}
const o = snapshot.places[oId];
const d = snapshot.places[dId];
if (!o || !d) continue;
for (const mode of modes) {
const modeKey = mode === 'WALK' ? 'WALKING' : 'DRIVING';
const rk = `${oId}>${dId}>${modeKey}`;
if (!full && snapshot.routes[rk]) continue;
if (pending.some(p => p.key === rk)) continue;
pending.push({ key: rk, oLat: o.lat, oLng: o.lng, dLat: d.lat, dLng: d.lng, mode });
}
}
const label = full ? '全量' : '增量';
console.log(` ${label}: ${pending.length} 条需计算`);
if (!pending.length) {
console.log(' 所有路线已完备,跳过');
return;
}
let fixed = 0;
const tasks = pending.map(({ key, oLat, oLng, dLat, dLng, mode }) => async () => {
const r = await withRetry(() => computeRoute(apiKey, oLat, oLng, dLat, dLng, mode), `route(${key.slice(0, 40)})`);
if (r) { snapshot.routes[key] = r; fixed++; }
});
await runConcurrent(tasks);
console.log(`\n ✓ 路线完成 ${fixed}/${pending.length},共 ${Object.keys(snapshot.routes).length} 条`);
}
// ─── CLI 解析 ────────────────────────────────────────────────────────────────
function parseArgs() {
const argv = process.argv.slice(2);
const full = new Set();
const skip = new Set();
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--full' && argv[i + 1]) {
for (const s of argv[++i].split(',')) {
if (s === 'all') { full.add('search'); full.add('autocomplete'); full.add('details'); full.add('routes'); }
else full.add(s);
}
} else if (argv[i] === '--skip' && argv[i + 1]) {
for (const s of argv[++i].split(',')) skip.add(s);
}
// 兼容旧 CLI
else if (argv[i] === '--patch') { /* noop — 增量已是默认行为 */ }
else if (argv[i] === '--full-search') { full.add('search'); }
else if (argv[i] === '--skip-details') { skip.add('details'); }
else if (argv[i] === '--skip-routes') { skip.add('routes'); }
}
return { full, skip };
}
// ─── 主入口 ──────────────────────────────────────────────────────────────────
async function main() {
console.log('='.repeat(60));
console.log('Map 地理信息快照生成器');
console.log('='.repeat(60));
const args = parseArgs();
const apiKey = readApiKey();
const loc = readSimulatedLocation();
console.log('\nAPI Key: configured');
console.log(`当前模拟位置: (${loc.latitude}, ${loc.longitude})`);
const stages = ['search', 'autocomplete', 'details', 'routes'];
const modeDesc = stages.map(s => {
if (args.skip.has(s)) return `${s}:跳过`;
if (args.full.has(s)) return `${s}:全量`;
return `${s}:增量`;
});
console.log(`模式: ${modeDesc.join(' | ')}`);
console.log('\n[1] 从 tasks.py 提取搜索查询...');
const queries = extractQueriesFromTasks();
console.log(` 分类: ${[...queries.categoryTypes.values()].join(', ')}`);
console.log(` 地名: ${queries.placeQueries.join(', ')}`);
console.log(` 搜索提示输入: ${queries.autocompleteInputs.length} 个`);
console.log(` 路线: ${queries.routePairs.map(([o, d]) => `${o}${d}`).join(', ') || '无'}`);
console.log('\n[2] 加载现有快照...');
const snapshot = loadSnapshot(loc);
console.log(` 已有 ${Object.keys(snapshot.places).length} POI, ${Object.keys(snapshot.searchIndex).length} 搜索索引, ${Object.keys(snapshot.autocompleteIndex).length} 搜索提示, ${Object.keys(snapshot.routes).length} 路线`);
pruneDistantPlaces(snapshot, loc);
if (!args.skip.has('search')) {
console.log('\n[3] 搜索...');
await runSearch(snapshot, queries, apiKey, loc, { full: args.full.has('search') });
saveSnapshot(snapshot);
} else {
console.log('\n[3] 跳过搜索');
}
if (!args.skip.has('autocomplete')) {
console.log('\n[4] 搜索提示...');
await runAutocomplete(snapshot, queries, apiKey, loc, { full: args.full.has('autocomplete') });
saveSnapshot(snapshot);
} else {
console.log('\n[4] 跳过搜索提示');
}
if (!args.skip.has('details')) {
console.log('\n[5] 详情...');
await runDetails(snapshot, apiKey, loc, { full: args.full.has('details') });
saveSnapshot(snapshot);
} else {
console.log('\n[5] 跳过详情');
}
if (!args.skip.has('routes')) {
console.log('\n[6] 路线...');
await runRoutes(snapshot, queries, apiKey, loc, { full: args.full.has('routes') });
saveSnapshot(snapshot);
} else {
console.log('\n[6] 跳过路线');
}
console.log('\n' + '='.repeat(60));
}
main().catch(e => { console.error('致命错误:', e); process.exit(1); });