import type { AirQualitySnapshot, Pollutants, PollenSnapshot, WeatherSnapshot } from './types' import { emptyPollen, emptyPollutants, levelFromAqiCn } from './scrape' const BEIJING_DISTRICTS = [ '东城', '西城', '朝阳', '海淀', '丰台', '石景山', '门头沟', '房山', '通州', '顺义', '昌平', '大兴', '怀柔', '平谷', '密云', '延庆', ] as const const DISTRICT_AREA: Record = { 东城: '110101', 西城: '110102', 朝阳: '110105', 丰台: '110106', 石景山: '110107', 海淀: '110108', 门头沟: '110109', 房山: '110111', 通州: '110112', 顺义: '110113', 昌平: '110114', 大兴: '110115', 怀柔: '110116', 平谷: '110117', 密云: '110118', 延庆: '110119', } const LEVEL_TEXT: Record = { 0: '暂无', 1: '低', 2: '较低', 3: '中等', 4: '高', 5: '很高', 6: '很高', } type StationRow = { staId: string staName: string hfH: number hfHLv: number nttimeter?: string advice?: string } type ForecastPoint = { areaCode: string; val: number; dataTime: string } type ClassifyRow = { plantName: string; level: number; description?: string } export function looksLikeBeijing(location?: string): { hit: boolean; district: string } { const raw = (location || '').trim() if (!raw) return { hit: false, district: '海淀' } const lower = raw.toLowerCase() if (lower.includes('beijing') || raw.includes('北京')) { for (const d of BEIJING_DISTRICTS) { if (raw.includes(d)) return { hit: true, district: d } } return { hit: true, district: '海淀' } } for (const d of BEIJING_DISTRICTS) { if (raw === d || raw === `${d}区` || raw.includes(d)) return { hit: true, district: d } } return { hit: false, district: '海淀' } } async function geocode(name: string): Promise<{ name: string latitude: number longitude: number country?: string admin1?: string timezone?: string }> { const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=1&language=en&format=json` const res = await fetch(url) if (!res.ok) throw new Error(`Geocoding failed: HTTP ${res.status}`) const data = (await res.json()) as { results?: { name: string latitude: number longitude: number country?: string admin1?: string timezone?: string }[] } const hit = data.results?.[0] if (!hit) throw new Error(`Place not found: ${name}`) return hit } export async function openMeteoFallback(input: { location?: string latitude?: number longitude?: number }): Promise< Pick< AirQualitySnapshot, | 'place' | 'aqi' | 'level' | 'pollutants' | 'pollen' | 'weather' | 'updateTime' | 'summary' | 'warnings' | 'coverageNote' > > { let latitude = input.latitude let longitude = input.longitude let placeName = input.location?.trim() || 'Unknown' let lat: number | undefined let lon: number | undefined if ( !( typeof latitude === 'number' && Number.isFinite(latitude) && typeof longitude === 'number' && Number.isFinite(longitude) ) ) { if (!input.location?.trim()) throw new Error('Open-Meteo fallback needs location or coordinates') const geo = await geocode(input.location.trim()) latitude = geo.latitude longitude = geo.longitude placeName = geo.name lat = geo.latitude lon = geo.longitude } else { lat = latitude lon = longitude } const aqUrl = `https://air-quality-api.open-meteo.com/v1/air-quality` + `?latitude=${latitude}&longitude=${longitude}` + `¤t=european_aqi,us_aqi,pm2_5,pm10,ozone,nitrogen_dioxide,sulphur_dioxide,carbon_monoxide,uv_index` + `&hourly=alder_pollen,birch_pollen,grass_pollen,mugwort_pollen,olive_pollen,ragweed_pollen` + `&forecast_hours=6&timezone=auto` const wxUrl = `https://api.open-meteo.com/v1/forecast` + `?latitude=${latitude}&longitude=${longitude}` + `¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,wind_direction_10m` + `&wind_speed_unit=kmh&timezone=auto` const [aqRes, wxRes] = await Promise.all([fetch(aqUrl), fetch(wxUrl)]) if (!aqRes.ok) throw new Error(`Open-Meteo air-quality failed: HTTP ${aqRes.status}`) if (!wxRes.ok) throw new Error(`Open-Meteo weather failed: HTTP ${wxRes.status}`) const aq = (await aqRes.json()) as { current?: Record hourly?: Record timezone?: string } const wx = (await wxRes.json()) as { current?: Record } const cur = aq.current || {} const aqi = typeof cur.us_aqi === 'number' ? cur.us_aqi : typeof cur.european_aqi === 'number' ? cur.european_aqi : null const pollutants: Pollutants = { pm25: typeof cur.pm2_5 === 'number' ? cur.pm2_5 : null, pm10: typeof cur.pm10 === 'number' ? cur.pm10 : null, o3: typeof cur.ozone === 'number' ? cur.ozone : null, no2: typeof cur.nitrogen_dioxide === 'number' ? cur.nitrogen_dioxide : null, co: typeof cur.carbon_monoxide === 'number' ? cur.carbon_monoxide : null, so2: typeof cur.sulphur_dioxide === 'number' ? cur.sulphur_dioxide : null, } const pollen = emptyPollen() const map: [keyof PollenSnapshot, string][] = [ ['alder', 'alder_pollen'], ['birch', 'birch_pollen'], ['grass', 'grass_pollen'], ['mugwort', 'mugwort_pollen'], ['olive', 'olive_pollen'], ['ragweed', 'ragweed_pollen'], ] let anyPollen = false for (const [k, hk] of map) { const v = aq.hourly?.[hk]?.[0] if (typeof v === 'number' && Number.isFinite(v)) { ;(pollen as Record)[k] = v anyPollen = true } } if (!anyPollen) { pollen.raw = null } const wcur = wx.current || {} const weather: WeatherSnapshot = { temperatureC: typeof wcur.temperature_2m === 'number' ? wcur.temperature_2m : null, humidityPct: typeof wcur.relative_humidity_2m === 'number' ? wcur.relative_humidity_2m : null, windSpeedKph: typeof wcur.wind_speed_10m === 'number' ? wcur.wind_speed_10m : null, windDirection: null, windDegrees: typeof wcur.wind_direction_10m === 'number' ? wcur.wind_direction_10m : null, uvIndex: typeof cur.uv_index === 'number' ? cur.uv_index : null, } const level = levelFromAqiCn(typeof aqi === 'number' ? aqi : null) const summaryParts = [`${placeName} (Open-Meteo fallback)`] if (aqi != null) summaryParts.push(`AQI ${aqi}`) if (pollutants.pm25 != null) summaryParts.push(`PM2.5 ${pollutants.pm25}`) return { place: { name: placeName, path: '', lat, lon }, aqi: typeof aqi === 'number' ? aqi : null, level, pollutants, pollen, weather, updateTime: typeof cur.time === 'string' ? cur.time : null, summary: summaryParts.join(' · '), warnings: [ 'Primary air-quality.com scrape failed or was skipped; using Open-Meteo fallback (EU/US AQI models, Europe-focused pollen).', ], coverageNote: 'Fallback: Open-Meteo global pollutants/AQI; CAMS pollen usually Europe-only. Not identical to air-quality.com fields.', } } export async function beijingPollenFallback(district: string): Promise<{ pollen: PollenSnapshot summary: string warnings: string[] placeName: string }> { const areaCode = DISTRICT_AREA[district] || DISTRICT_AREA['海淀']! const base = 'https://pollenwechat.bjpws.com' const stationsRes = await fetch(`${base}/api/pollen/obs/latestPollenLevels`) if (!stationsRes.ok) throw new Error(`Beijing stations failed: HTTP ${stationsRes.status}`) const stationsJson = (await stationsRes.json()) as { code?: number data?: StationRow[] } if (stationsJson.code != null && stationsJson.code !== 200) { throw new Error(`Beijing stations API error: ${stationsJson.code}`) } const stations = stationsJson.data || [] const station = stations.find((s) => s.staName.includes(district)) || stations[0] if (!station) throw new Error(`No Beijing station for district ${district}`) const forecastRes = await fetch(`${base}/v1/pollen/forecast?plantCode=zongleibie`) const classifyRes = await fetch(`${base}/v2/pollen/classify/forecast?areaCode=${areaCode}`) const forecastJson = forecastRes.ok ? ((await forecastRes.json()) as { data?: { values?: { '24'?: Record } } }) : null const classifyJson = classifyRes.ok ? ((await classifyRes.json()) as { data?: { value?: Record } }) : null const levelCode = Number(station.hfHLv) || 0 const levelText = LEVEL_TEXT[levelCode] || '暂无' const pollen = emptyPollen() pollen.raw = `${station.hfH}(${levelText})` pollen.max = Number(station.hfH) pollen.total = Number(station.hfH) pollen.allergyRisk = levelCode || null const series = forecastJson?.data?.values?.['24'] || {} const keys = Object.keys(series).sort() const today = keys[0] ? series[keys[0]]?.find((x) => x.areaCode === areaCode) : null if (today) { pollen.allergyRisk = today.val } const classifyBucket = classifyJson?.data?.value if (classifyBucket) { const firstKey = Object.keys(classifyBucket).sort()[0] const cats = firstKey ? classifyBucket[firstKey]?.data || [] : [] for (const c of cats) { const n = c.plantName || '' if (n.includes('蒿')) pollen.mugwort = c.level else if (n.includes('藜')) pollen.ragweed = c.level } } return { pollen, placeName: station.staName, summary: `${station.staName} 花粉监测 ${pollen.raw}` + (station.advice ? `;${station.advice}` : ''), warnings: [ 'Using 北京花粉监测公共服务平台 fallback (district station levels / forecast). Attribution: pollenwechat.bjpws.com; approach documented via RuochenLyu/beijing-pollen-monitor.', ], } } export { emptyPollutants }