#!/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_PAIRS(origin_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 = 'Map SDK Snapshot'; 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); });