Skip to content

src/fallback.ts

312 lines · 9.4 KB · TypeScript
Raw
import 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}` +
		`&current=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}` +
		`&current=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 }