← Public packages
@reng/air-quality
Scrape air-quality.com for AQI, pollutants, pollen, and weather (HA-style field set).
src/fallback.ts
312 lines · 9.4 KB · TypeScriptimport type { AirQualitySnapshot, Pollutants, PollenSnapshot, WeatherSnapshot } from './types'
import { emptyPollen, emptyPollutants, levelFromAqiCn } from './scrape'
const BEIJING_DISTRICTS = [
'东城',
'西城',
'朝阳',
'海淀',
'丰台',
'石景山',
'门头沟',
'房山',
'通州',
'顺义',
'昌平',
'大兴',
'怀柔',
'平谷',
'密云',
'延庆',
] as const
const DISTRICT_AREA: Record<string, string> = {
东城: '110101',
西城: '110102',
朝阳: '110105',
丰台: '110106',
石景山: '110107',
海淀: '110108',
门头沟: '110109',
房山: '110111',
通州: '110112',
顺义: '110113',
昌平: '110114',
大兴: '110115',
怀柔: '110116',
平谷: '110117',
密云: '110118',
延庆: '110119',
}
const LEVEL_TEXT: Record<number, string> = {
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<string, number | null | string>
hourly?: Record<string, (number | null)[]>
timezone?: string
}
const wx = (await wxRes.json()) as { current?: Record<string, number | null | string> }
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<string, unknown>)[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<string, ForecastPoint[]> } }
})
: null
const classifyJson = classifyRes.ok
? ((await classifyRes.json()) as {
data?: { value?: Record<string, { data?: ClassifyRow[] }> }
})
: 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 }