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 { 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