项目文件夹

文件
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

395 行
15 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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);
});