Skip to content

src/getSnapshot.ts

158 lines · 4.9 KB · TypeScript
Raw
import {
	AQI_STANDARDS,
	buildSummary,
	fetchAndParsePlace,
	pickBestHit,
	searchPlaces,
} from './scrape'
import { beijingPollenFallback, looksLikeBeijing, openMeteoFallback } from './fallback'
import type { AirQualitySnapshot, AqiStandard } from './types'

export type { AirQualitySnapshot, AqiStandard }
export { AQI_STANDARDS }

export type GetSnapshotInput = {
	/** City / district / station name (Chinese or English). Used for air-quality.com search. */
	location?: string
	/** Direct place path from searchPlaces, e.g. `china/beijing/ec8399ca`. Skips search when set. */
	place?: string
	/** Display name when `place` is provided without search. */
	placeName?: string
	latitude?: number
	longitude?: number
	/** AQI standard query param (default `aqi_cn`). */
	standard?: AqiStandard
	/** When scrape fails, try Open-Meteo (+ Beijing pollen for 北京/districts). Default true. */
	fallback?: boolean
}

/**
 * Fetch a full outdoor snapshot (AQI, pollutants, pollen, weather) by scraping air-quality.com,
 * matching the Home Assistant `yahooor/air_quality_cn` field set. Use for a one-shot place pulse.
 * Upstream HTML scraping can break without notice — optional Open-Meteo / Beijing pollen fallback.
 *
 * @param input - `location` and/or `place`, optional `standard`, `fallback`
 * @returns Unified snapshot with `source`, pollutants, pollen, weather, summary
 *
 * @example
 * import { getSnapshot } from 'kody:@reng/air-quality/getSnapshot'
 *
 * const bj = await getSnapshot({ location: '北京', standard: 'aqi_cn' })
 * // { aqi, pollutants: { pm25, ... }, pollen, weather, summary, source: 'air-quality.com' }
 */
export async function getSnapshot(input: GetSnapshotInput = {}): Promise<AirQualitySnapshot> {
	const standard: AqiStandard = input.standard ?? 'aqi_cn'
	const allowFallback = input.fallback !== false
	const warnings: string[] = []

	if (!AQI_STANDARDS[standard]) {
		throw new Error(`Unknown AQI standard: ${standard}`)
	}

	let placePath = input.place?.trim() || ''
	let placeName = input.placeName?.trim() || ''
	let lat = input.latitude
	let lon = input.longitude
	let description: string | undefined

	try {
		if (!placePath) {
			const loc = input.location?.trim()
			if (!loc) throw new Error('Provide location (place name) or place path')
			const hits = await searchPlaces(loc, 20)
			const best = pickBestHit(hits, loc)
			placePath = best.place
			placeName = best.name
			lat = best.lat
			lon = best.lon
			description = best.description
		} else if (!placeName) {
			placeName = input.location?.trim() || placePath
		}

		const { url, parsed } = await fetchAndParsePlace(placePath, standard)
		warnings.push(...parsed.warnings)

		const summary = buildSummary({
			placeName,
			aqi: parsed.aqi,
			level: parsed.level,
			pollutants: parsed.pollutants,
			pollen: parsed.pollen,
			weather: parsed.weather,
		})

		return {
			place: { name: placeName, path: placePath, lat, lon, description },
			standard,
			source: 'air-quality.com',
			url,
			aqi: parsed.aqi,
			level: parsed.level,
			pollutants: parsed.pollutants,
			pollen: parsed.pollen,
			weather: parsed.weather,
			updateTime: parsed.updateTime,
			summary,
			attribution:
				'Scraped from air-quality.com (same approach as Home Assistant yahooor/air_quality_cn). HTML may change and break parsing. Not medical advice.',
			warnings,
			coverageNote:
				'Primary source scrapes air-quality.com place pages (AQI standard selectable). Pollutants μg/m³; pollen seasonal; weather from page/curWeatherData.',
		}
	} catch (err) {
		const msg = err instanceof Error ? err.message : String(err)
		if (!allowFallback) throw err
		warnings.push(`air-quality.com scrape failed: ${msg}`)

		const om = await openMeteoFallback({
			location: input.location || placeName || undefined,
			latitude: lat,
			longitude: lon,
		})
		warnings.push(...om.warnings)

		let source: AirQualitySnapshot['source'] = 'open-meteo-fallback'
		let pollen = om.pollen
		const bj = looksLikeBeijing(input.location || placeName)
		if (bj.hit) {
			try {
				const bp = await beijingPollenFallback(bj.district)
				pollen = bp.pollen
				warnings.push(...bp.warnings)
				source = 'mixed-fallback'
				om.summary = `${om.summary} · ${bp.summary}`
			} catch (e2) {
				warnings.push(
					`Beijing pollen fallback failed: ${e2 instanceof Error ? e2.message : String(e2)}`,
				)
			}
		}

		return {
			place: {
				name: om.place.name || placeName || 'Unknown',
				path: placePath,
				lat: om.place.lat ?? lat,
				lon: om.place.lon ?? lon,
				description,
			},
			standard,
			source,
			url: null,
			aqi: om.aqi,
			level: om.level,
			pollutants: om.pollutants,
			pollen,
			weather: om.weather,
			updateTime: om.updateTime,
			summary: om.summary,
			attribution:
				'Fallback data: Open-Meteo (CC BY 4.0) and/or 北京花粉监测公共服务平台. Primary scrape of air-quality.com failed. Not medical advice.',
			warnings,
			coverageNote: om.coverageNote,
		}
	}
}

export default getSnapshot