← Public packages
@reng/air-quality
Scrape air-quality.com for AQI, pollutants, pollen, and weather (HA-style field set).
src/getSnapshot.ts
158 lines · 4.9 KB · TypeScriptimport {
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