use clap::Parser; use derive_more::From; use scraper::{Html, Selector}; use colored::Colorize; #[derive(Default, Parser)] #[clap(author, version, about)] struct Arguments { #[clap(help = "Plume Labs location ID. For example, https://air.plumelabs.com/air-quality-in-XXX")] location: String, #[clap(short, long, help = "Display raw value without descriptor")] raw: bool, #[clap(short, long, help = "Display raw concentration in µg/m³")] concentration: bool, #[clap(short = 'p', long = "pm25", help = "Display fine PM2.5 inhalable particulate matter")] fine_particulate: bool, #[clap(short = 'P', long = "pm10", help = "Display coarse PM10 inhalable particulate matter")] coarse_particulate: bool, #[clap(short, long = "no2", help = "Display nitrogen dioxide NO2")] nitrogen_dioxide: bool, #[clap(short, long = "o3", help = "Display ozone O3")] ozone: bool, #[clap(short, long, help = "Use color formatting tags for lemonbar and polybar")] bar: bool, } #[derive(From, Debug)] enum Error { ReqwestError(reqwest::Error), HttpError, InvalidResponse, } struct Reading { concentration: u32, aqi: u32, } struct Data { aqi: u32, fine_particulate: Reading, coarse_particulate: Reading, nitrogen_dioxide: Reading, ozone: Reading, } impl Data { fn fetch(location: &str) -> Result { let response = reqwest::blocking::get(format!("https://air.plumelabs.com/air-quality-in-{location}"))?; if !response.status().is_success() { return Err(Error::HttpError); } let content = response.text_with_charset("utf-8")?; let document = Html::parse_document(&content); let get_value = |selector: &str| { let selector = selector.to_owned(); let selector = Selector::parse(&selector).unwrap(); Ok(match match document.select(&selector).next() { Some(element) => element, None => return Err(Error::InvalidResponse), }.inner_html().trim().parse::() { Ok(aqi) => aqi, Err(_) => return Err(Error::InvalidResponse), } as u32) }; let get_reading = |name| -> Result { Ok(Reading { concentration: get_value(&format!("li[data-id=\"{name}\"][data-format=\"value_upm\"] div[data-role=\"pollutant-level\"]"))?, aqi: get_value(&format!("li[data-id=\"{name}\"][data-format=\"pi\"] div[data-role=\"pollutant-level\"]"))?, }) }; Ok(Self { aqi: get_value("span[data-role=\"current-pi\"")?, fine_particulate: get_reading("PM25")?, coarse_particulate: get_reading("PM10")?, nitrogen_dioxide: get_reading("NO2")?, ozone: get_reading("O3")?, }) } } fn main() -> Result<(), Error> { let arguments = Arguments::parse(); let data = Data::fetch(&arguments.location)?; let descriptor; let Reading { aqi, concentration } = if arguments.fine_particulate { descriptor = "PM2.5 "; data.fine_particulate } else if arguments.coarse_particulate { descriptor = "PM10 "; data.coarse_particulate } else if arguments.nitrogen_dioxide { descriptor = "NO2 "; data.nitrogen_dioxide } else if arguments.ozone { descriptor = "O3 "; data.ozone } else { descriptor = ""; Reading { aqi: data.aqi, concentration: data.aqi, } }; if arguments.raw { println!("{}", if arguments.concentration { concentration } else { aqi }); return Ok(()); } let display = if arguments.concentration { format!("{concentration} µg/m³") } else { format!("{aqi} AQI") }; if arguments.bar { print!("%{{F#{}}}", match aqi { 150.. => "b48ead", 100.. => "bf616a", 50.. => "d08770", 20.. => "ebcb8b", 0.. => "a3be8c", }); } print!("{}", descriptor); println!("{} {}", match aqi { 250.. => "Dangerous".purple(), 150.. => "Very Unhealthy".purple(), 100.. => "Unhealthy".magenta(), 50.. => "Poor".red(), 20.. => "Fair".yellow(), 0.. => "Excellent".green(), }.bold(), match aqi { // 250.. => (no appropriate terminal color) // Dangerous 250+ 150.. => display.purple(), // Very Unhealthy 150-249 100.. => display.magenta(), // Unhealthy 100-149 50.. => display.red(), // Poor 50-99 20.. => display.yellow(), // Fair 20-49 0.. => display.green(), // Excellent 0-19 }.bold() ); if arguments.bar { print!("%{{F-}}"); } return Ok(()); }