purewhiter--mobilegym
395 行
15 KiB
JavaScript
395 行
15 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Weather App 的可选天气快照生成工具。
|
||
* 调用和风天气 / 高德官方 API,刷新 apps/Weather/data/weatherBundles.json。
|
||
*
|
||
* 用法:
|
||
* node apps/Weather/scripts/fetch_weather_snapshot.mjs # 获取所有城市
|
||
* node apps/Weather/scripts/fetch_weather_snapshot.mjs --cities beijing,shanghai # 只获取指定城市
|
||
*
|
||
* 环境变量:
|
||
* VITE_QWEATHER_API_KEY — 和风天气 API Key(必须)
|
||
* VITE_QWEATHER_HOST — 和风天气 API 地址(必须)
|
||
* VITE_AMAP_API_KEY — 高德逆地理编码 Key(可选,用于 located 城市名)
|
||
*
|
||
* 输出: apps/Weather/data/weatherBundles.json
|
||
*/
|
||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||
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, '../..');
|
||
|
||
// 手动解析 .env / .env.local(避免依赖 dotenv),与 Vite 常用优先级保持一致:
|
||
// process.env > .env.local > .env
|
||
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 OUT_PATH = resolve(APP_DIR, 'data/weatherBundles.json');
|
||
const DEFAULTS_PATH = resolve(APP_DIR, 'data/defaults.json');
|
||
const SEARCHABLE_CITIES_PATH = resolve(APP_DIR, 'data/searchableCities.json');
|
||
const WEATHER_APP_PY = resolve(ROOT, 'bench_env/task/weather/app.py');
|
||
const SIMULATOR_CONFIG_PATH = resolve(ROOT, 'os/data/simulatorConfig.ts');
|
||
|
||
const QWEATHER_API_KEY = process.env.VITE_QWEATHER_API_KEY;
|
||
const QWEATHER_HOST = process.env.VITE_QWEATHER_HOST;
|
||
const AMAP_API_KEY = process.env.VITE_AMAP_API_KEY;
|
||
|
||
if (!QWEATHER_HOST) {
|
||
console.error('错误: 缺少 VITE_QWEATHER_HOST,请在 .env.local 或 .env 中设置');
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!QWEATHER_API_KEY) {
|
||
console.error('错误: 缺少 VITE_QWEATHER_API_KEY,请在 .env.local 或 .env 中设置');
|
||
process.exit(1);
|
||
}
|
||
|
||
// ─── 从项目文件读取城市列表 ──────────────────────────────────────────────────
|
||
|
||
const defaults = JSON.parse(readFileSync(DEFAULTS_PATH, 'utf-8'));
|
||
const searchableCities = JSON.parse(readFileSync(SEARCHABLE_CITIES_PATH, 'utf-8'));
|
||
|
||
// bench_env/task/weather/app.py 中的 WEATHER_NEW_CITIES
|
||
function parseNewCitiesFromAppPy() {
|
||
const src = readFileSync(WEATHER_APP_PY, 'utf-8');
|
||
const m = src.match(/WEATHER_NEW_CITIES\s*=\s*\[([^\]]+)\]/);
|
||
if (!m) return [];
|
||
return [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
||
}
|
||
|
||
// 按城市名在 searchableCities / defaults 中查找坐标
|
||
function lookupCity(name) {
|
||
const key = name.replace(/[市省]$/, '');
|
||
for (const c of [...defaults.savedCities, ...searchableCities]) {
|
||
const cName = (c.name || '').replace(/[市省]$/, '');
|
||
if (cName === key || cName === name) return c;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const newCityNames = parseNewCitiesFromAppPy();
|
||
const extraCities = newCityNames
|
||
.map((name) => {
|
||
const found = lookupCity(name);
|
||
if (!found) {
|
||
console.warn(`⚠ bench 城市 "${name}" 未在 searchableCities 中找到,跳过`);
|
||
return null;
|
||
}
|
||
return { id: found.id, name: found.name, lon: found.lon, lat: found.lat };
|
||
})
|
||
.filter(Boolean);
|
||
|
||
// os/data/simulatorConfig.ts 中的默认定位坐标
|
||
function parseDefaultLocation() {
|
||
const src = readFileSync(SIMULATOR_CONFIG_PATH, 'utf-8');
|
||
const m = src.match(/simulatedLocation\s*:\s*\{\s*latitude\s*:\s*([\d.]+)\s*,\s*longitude\s*:\s*([\d.]+)/);
|
||
if (!m) {
|
||
console.warn('⚠ 未能从 simulatorConfig.ts 解析默认定位坐标,使用 Beijing fallback');
|
||
return { lat: 39.9042, lon: 116.4074 };
|
||
}
|
||
return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) };
|
||
}
|
||
|
||
const defaultLocation = parseDefaultLocation();
|
||
const locatedCity = { id: 'located', name: '', lon: defaultLocation.lon, lat: defaultLocation.lat };
|
||
|
||
const ALL_CITIES = [...defaults.savedCities, ...extraCities, locatedCity];
|
||
console.log(`城市列表: ${ALL_CITIES.map((c) => c.id).join(', ')}`);
|
||
|
||
// ─── 请求工具 ────────────────────────────────────────────────────────────────
|
||
|
||
async function qweatherFetch(path, params = {}) {
|
||
const url = new URL(`${QWEATHER_HOST.replace(/\/+$/, '')}${path}`);
|
||
for (const [k, v] of Object.entries(params)) url.searchParams.append(k, v);
|
||
const res = await fetch(url.toString(), {
|
||
headers: { 'X-QW-Api-Key': QWEATHER_API_KEY },
|
||
});
|
||
if (!res.ok) throw new Error(`QWeather ${path} → ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
async function amapReverseGeocode(lon, lat) {
|
||
if (!AMAP_API_KEY) return null;
|
||
const url = `https://restapi.amap.com/v3/geocode/regeo?key=${AMAP_API_KEY}&location=${lon},${lat}&radius=1000&extensions=base&output=JSON`;
|
||
const res = await fetch(url);
|
||
if (!res.ok) return null;
|
||
const data = await res.json();
|
||
const comp = data?.regeocode?.addressComponent;
|
||
if (!comp) return null;
|
||
const road = comp.street || comp.township || comp.neighborhood || '';
|
||
const district = comp.district || '';
|
||
if (road) return district ? `${district} ${road}` : road;
|
||
if (district) return district;
|
||
return comp.city || null;
|
||
}
|
||
|
||
// ─── 蒲福风级转换(与 weatherService.ts 一致) ──────────────────────────────
|
||
|
||
function beaufortScaleFromKmh(kmh) {
|
||
const thresholds = [1, 6, 12, 20, 29, 39, 50, 62, 75, 89, 103, 118, 134, 150, 167, 184, 202];
|
||
const speed = Math.abs(kmh);
|
||
for (let i = 0; i < thresholds.length; i++) {
|
||
if (speed < thresholds[i]) return String(i);
|
||
}
|
||
return '17';
|
||
}
|
||
|
||
function normalizeWindScale(day, dayKey, nightKey, speedDayKey, speedNightKey) {
|
||
const entry = { ...day };
|
||
const wsDay = parseFloat(entry[speedDayKey] || '');
|
||
const wsNight = parseFloat(entry[speedNightKey] || '');
|
||
if (Number.isFinite(wsDay)) entry[dayKey] = beaufortScaleFromKmh(wsDay);
|
||
if (Number.isFinite(wsNight)) entry[nightKey] = beaufortScaleFromKmh(wsNight);
|
||
return entry;
|
||
}
|
||
|
||
// ─── 获取单城市 ──────────────────────────────────────────────────────────────
|
||
|
||
async function fetchCityBundle(city) {
|
||
const location = `${city.lon},${city.lat}`;
|
||
console.log(` 获取 ${city.name} (${city.id}) ...`);
|
||
|
||
const [nowData, dailyData, hourlyData, indicesData, warningData, aqData] = await Promise.all([
|
||
qweatherFetch('/v7/weather/now', { location }),
|
||
qweatherFetch('/v7/weather/15d', { location }),
|
||
qweatherFetch('/v7/weather/24h', { location }),
|
||
qweatherFetch('/v7/indices/1d', { location, type: '1,2,3,5,9' }),
|
||
qweatherFetch('/v7/warning/now', { location }),
|
||
qweatherFetch(`/airquality/v1/current/${city.lat}/${city.lon}`).catch(() => null),
|
||
]);
|
||
|
||
const now = nowData.now || {};
|
||
const daily = (dailyData.daily || []).map((d) =>
|
||
normalizeWindScale(d, 'windScaleDay', 'windScaleNight', 'windSpeedDay', 'windSpeedNight'),
|
||
);
|
||
const hourly = (hourlyData.hourly || []).map((h) => {
|
||
const ws = parseFloat(h.windSpeed || '');
|
||
return { ...h, windScale: Number.isFinite(ws) ? beaufortScaleFromKmh(ws) : (h.windScale || '') };
|
||
});
|
||
const indices = indicesData.daily || [];
|
||
|
||
const warnings = (warningData.warning || []).map((w) => ({
|
||
id: w.id || '',
|
||
sender: w.sender || '',
|
||
pubTime: w.pubTime || '',
|
||
title: w.title || '',
|
||
startTime: w.startTime || '',
|
||
endTime: w.endTime || '',
|
||
status: w.status || '预警中',
|
||
level: w.level || '',
|
||
severity: w.severity || '',
|
||
severityColor: w.severityColor || 'Blue',
|
||
type: w.type || '',
|
||
typeName: w.typeName || '',
|
||
text: w.text || '',
|
||
related: w.related || '',
|
||
}));
|
||
|
||
let airQuality = null;
|
||
if (aqData?.indexes?.[0]) {
|
||
const idx = aqData.indexes[0];
|
||
const pv = (code) => {
|
||
const m = (aqData.pollutants || []).find((p) => (p.code || '').toLowerCase() === code.toLowerCase());
|
||
return String(m?.concentration?.value ?? '');
|
||
};
|
||
airQuality = {
|
||
pubTime: new Date().toISOString(),
|
||
aqi: String(idx.aqiDisplay ?? idx.aqi ?? ''),
|
||
level: String(idx.level ?? ''),
|
||
category: String(idx.category ?? ''),
|
||
primaryPollutant: String(idx.primaryPollutant?.fullName ?? idx.primaryPollutant?.name ?? ''),
|
||
pm10: pv('pm10'),
|
||
pm2p5: pv('pm2p5'),
|
||
no2: pv('no2'),
|
||
so2: pv('so2'),
|
||
co: pv('co'),
|
||
o3: pv('o3'),
|
||
};
|
||
}
|
||
|
||
let minutely = null;
|
||
try {
|
||
const minutelyData = await qweatherFetch('/v7/minutely/5m', { location });
|
||
if (minutelyData.summary) {
|
||
minutely = { summary: minutelyData.summary, minutely: minutelyData.minutely || [] };
|
||
}
|
||
} catch { /* optional */ }
|
||
|
||
const bundle = { now, daily, hourly, indices, warnings, airQuality, minutely };
|
||
|
||
// 历史天气(昨天)
|
||
let historicalYesterday = null;
|
||
try {
|
||
const geoData = await qweatherFetch('/geo/v2/city/lookup', { location });
|
||
const locationId = geoData.location?.[0]?.id;
|
||
if (locationId) {
|
||
const yesterday = new Date();
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
const dateParam = `${yesterday.getFullYear()}${String(yesterday.getMonth() + 1).padStart(2, '0')}${String(yesterday.getDate()).padStart(2, '0')}`;
|
||
const histData = await qweatherFetch('/v7/historical/weather', {
|
||
location: locationId,
|
||
date: dateParam,
|
||
});
|
||
if (histData.weatherDaily?.date) {
|
||
const hd = histData.weatherDaily;
|
||
const hh = histData.weatherHourly || [];
|
||
const pickHour = (hours, target) => {
|
||
if (!hours.length) return undefined;
|
||
let best = hours[0];
|
||
let bestDiff = Infinity;
|
||
for (const h of hours) {
|
||
const m = h.time?.match(/T(\d{1,2}):/);
|
||
if (m) {
|
||
const diff = Math.abs(parseInt(m[1]) - target);
|
||
if (diff < bestDiff) { best = h; bestDiff = diff; }
|
||
}
|
||
}
|
||
return best;
|
||
};
|
||
const dayHour = pickHour(hh, 14);
|
||
const nightHour = pickHour(hh, 21);
|
||
const wsDay = String(dayHour?.windSpeed || '');
|
||
const wsNight = String(nightHour?.windSpeed || '');
|
||
historicalYesterday = {
|
||
fxDate: String(hd.date || ''),
|
||
sunrise: String(hd.sunrise || ''),
|
||
sunset: String(hd.sunset || ''),
|
||
moonrise: String(hd.moonrise || ''),
|
||
moonset: String(hd.moonset || ''),
|
||
moonPhase: String(hd.moonPhase || ''),
|
||
moonPhaseIcon: '',
|
||
tempMax: String(hd.tempMax || ''),
|
||
tempMin: String(hd.tempMin || ''),
|
||
iconDay: String(dayHour?.icon || ''),
|
||
textDay: String(dayHour?.text || ''),
|
||
iconNight: String(nightHour?.icon || ''),
|
||
textNight: String(nightHour?.text || ''),
|
||
wind360Day: String(dayHour?.wind360 || ''),
|
||
windDirDay: String(dayHour?.windDir || ''),
|
||
windScaleDay: Number.isFinite(parseFloat(wsDay)) ? beaufortScaleFromKmh(parseFloat(wsDay)) : '',
|
||
windSpeedDay: wsDay,
|
||
wind360Night: String(nightHour?.wind360 || ''),
|
||
windDirNight: String(nightHour?.windDir || ''),
|
||
windScaleNight: Number.isFinite(parseFloat(wsNight)) ? beaufortScaleFromKmh(parseFloat(wsNight)) : '',
|
||
windSpeedNight: wsNight,
|
||
humidity: String(hd.humidity || ''),
|
||
precip: String(hd.precip || ''),
|
||
pressure: String(hd.pressure || ''),
|
||
vis: '',
|
||
cloud: '',
|
||
uvIndex: '',
|
||
};
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log(` 历史天气获取失败: ${e.message}`);
|
||
}
|
||
|
||
// 空气质量多日预报
|
||
let airQualityForecast = null;
|
||
try {
|
||
const aqfData = await qweatherFetch(`/airquality/v1/daily/${city.lat}/${city.lon}`);
|
||
if (aqfData.days?.length) {
|
||
airQualityForecast = aqfData.days
|
||
.map((day) => {
|
||
const idx = day.indexes?.[0];
|
||
if (!idx) return null;
|
||
return {
|
||
forecastStartTime: String(day.forecastStartTime || ''),
|
||
forecastEndTime: String(day.forecastEndTime || ''),
|
||
aqi: String(idx.aqiDisplay ?? idx.aqi ?? ''),
|
||
level: String(idx.level ?? ''),
|
||
category: String(idx.category ?? ''),
|
||
primaryPollutant: String(idx.primaryPollutant?.fullName ?? idx.primaryPollutant?.name ?? ''),
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
} catch { /* optional */ }
|
||
|
||
// 逆地理编码(仅定位页需要,但也给所有城市备一份)
|
||
let locationName = city.name.replace(/[市省]$/, '') || city.name;
|
||
try {
|
||
const amapName = await amapReverseGeocode(city.lon, city.lat);
|
||
if (amapName) locationName = amapName;
|
||
} catch { /* optional */ }
|
||
|
||
return {
|
||
lonLat: location,
|
||
locationName,
|
||
bundle,
|
||
historicalYesterday,
|
||
airQualityForecast,
|
||
};
|
||
}
|
||
|
||
// ─── 主入口 ──────────────────────────────────────────────────────────────────
|
||
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
const citiesArg = args.find((a) => a.startsWith('--cities='))?.split('=')[1];
|
||
const cidx = args.indexOf('--cities');
|
||
const citiesFilter = citiesArg
|
||
? citiesArg.split(',')
|
||
: cidx >= 0 && args[cidx + 1]
|
||
? args[cidx + 1].split(',')
|
||
: null;
|
||
|
||
const cities = citiesFilter
|
||
? ALL_CITIES.filter((c) => citiesFilter.includes(c.id))
|
||
: ALL_CITIES;
|
||
|
||
if (!cities.length) {
|
||
console.error('没有匹配的城市');
|
||
process.exit(1);
|
||
}
|
||
|
||
const existing = existsSync(OUT_PATH) ? JSON.parse(readFileSync(OUT_PATH, 'utf-8')) : {};
|
||
|
||
console.log(`获取 ${cities.length} 个城市的天气数据...`);
|
||
for (const city of cities) {
|
||
try {
|
||
existing[city.id] = await fetchCityBundle(city);
|
||
console.log(` ✓ ${city.name}`);
|
||
} catch (e) {
|
||
console.error(` ✗ ${city.name}: ${e.message}`);
|
||
}
|
||
// 限流:每个城市间隔 500ms
|
||
await new Promise((r) => setTimeout(r, 500));
|
||
}
|
||
|
||
writeFileSync(OUT_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||
console.log(`\n已写入 ${OUT_PATH}(${Object.keys(existing).length} 个城市)`);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error(e);
|
||
process.exit(1);
|
||
});
|