use crate::{
settings::{Observatory, Settings},
utils::is_visible_with_observatory,
};
use anyhow::{anyhow, Context, Result};
use chrono::{Datelike, TimeZone, Timelike, Utc};
use percent_encoding::percent_decode_str;
use regex::Regex;
use reqwest;
use serde::{Deserialize, Serialize};
use std::time::Duration;
const MPC_WHATSUP_INDEX_URL: &str = "https://www.minorplanetcenter.net/whatsup/index";
const MPC_WHATSUP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const MPC_WHATSUP_AUTH_TOKEN_FALLBACK: &str = "W5eBzzw9Clj4tJVzkz0z%2F2EK18jvSS%2BffHxZpAshylg%3D";
fn mpc_http_client() -> Result<reqwest::blocking::Client> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
),
);
headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static(
"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
),
);
reqwest::blocking::Client::builder()
.default_headers(headers)
.timeout(MPC_WHATSUP_REQUEST_TIMEOUT)
.build()
.context("Failed to build HTTP client for MPC")
}
fn extract_authenticity_token_from_html(html: &str) -> String {
let document = scraper::Html::parse_document(html);
if let Ok(selector) = scraper::Selector::parse(r#"input[name="authenticity_token"]"#) {
for element in document.select(&selector) {
if let Some(value) = element.value().attr("value") {
if !value.is_empty() {
return value.to_string();
}
}
}
}
if let Ok(re) = Regex::new(r#"name=["']authenticity_token["'][^>]*value=["']([^"']+)["']"#) {
if let Some(caps) = re.captures(html) {
if let Some(m) = caps.get(1) {
return m.as_str().to_string();
}
}
}
if let Ok(re) = Regex::new(r#"<meta\s+name=["']csrf-token["']\s+content=["']([^"']+)["']"#) {
if let Some(caps) = re.captures(html) {
if let Some(m) = caps.get(1) {
return m.as_str().to_string();
}
}
}
String::new()
}
fn scrape_whatsup_authenticity_token() -> String {
let client = match mpc_http_client() {
Ok(client) => client,
Err(_) => return String::new(),
};
let response = match client.get(MPC_WHATSUP_INDEX_URL).send() {
Ok(response) if response.status().is_success() => response,
_ => return String::new(),
};
let html = match response.text() {
Ok(html) => html,
Err(_) => return String::new(),
};
extract_authenticity_token_from_html(&html)
}
fn resolve_whatsup_authenticity_token() -> (String, bool) {
let scraped = scrape_whatsup_authenticity_token();
if !scraped.is_empty() {
return (scraped, false);
}
(MPC_WHATSUP_AUTH_TOKEN_FALLBACK.to_string(), true)
}
pub mod table_indices {
pub const DESIGNATION: usize = 0;
pub const MAGNITUDE: usize = 1;
pub const SOLAR_ELONG: usize = 2;
pub const LUNAR_ELONG: usize = 3;
pub const BEGIN_TIME: usize = 4;
pub const BEG_RA: usize = 5;
pub const BEG_DEC: usize = 6;
pub const BEG_ALT: usize = 7;
pub const MAX_TIME: usize = 8;
pub const MAX_RA: usize = 9;
pub const MAX_DEC: usize = 10;
pub const MAX_ALT: usize = 11;
pub const END_TIME: usize = 12;
pub const END_RA: usize = 13;
pub const END_DEC: usize = 14;
pub const END_ALT: usize = 15;
}
#[derive(Debug, Deserialize, Serialize)]
pub struct PossibleTarget {
pub designation: String,
pub ra: String,
pub dec: String,
pub magnitude: f32,
pub altitude: f32,
}
#[derive(Debug)]
pub struct WhatsUpParams {
pub year: String,
pub month: String,
pub day: String,
pub hour: String,
pub minute: String,
pub duration: String,
pub max_objects: String,
pub min_alt: String,
pub solar_elong: String,
pub lunar_elong: String,
pub object_type: String,
}
impl Default for WhatsUpParams {
fn default() -> Self {
let current_datetime = Utc::now();
let params: WhatsUpParams = WhatsUpParams {
year: current_datetime.year().to_string(),
month: current_datetime.month().to_string(),
day: current_datetime.day().to_string(),
minute: current_datetime.minute().to_string(),
hour: current_datetime.hour().to_string(),
duration: "1".to_string(),
max_objects: "10".to_string(),
min_alt: "10".to_string(),
solar_elong: "0".to_string(),
lunar_elong: "0".to_string(),
object_type: "mp".to_string(),
};
params
}
}
impl Default for PossibleTarget {
fn default() -> Self {
PossibleTarget {
designation: "None".to_string(),
ra: "None".to_string(),
dec: "None".to_string(),
magnitude: 0.0,
altitude: 0.0,
}
}
}
fn get_observing_target_list(params: &WhatsUpParams) -> Result<String> {
let settings = Settings::new().context("Failed to load settings")?;
let mut full_params: Vec<(&str, &str)> = Vec::new();
let encoded_param = "%E2%9C%93";
let decoded = percent_decode_str(encoded_param)
.decode_utf8_lossy()
.into_owned();
full_params.push(("utf8", decoded.as_str()));
let (auth_token, used_fallback) = resolve_whatsup_authenticity_token();
if used_fallback {
eprintln!(
"Warning: could not scrape MPC authenticity_token; using built-in fallback / \
Avviso: impossibile recuperare authenticity_token da MPC; uso fallback incorporato"
);
}
let decoded_auth_token = percent_decode_str(&auth_token)
.decode_utf8_lossy()
.into_owned();
full_params.push(("authenticity_token", decoded_auth_token.as_str()));
let latitude = settings.get_latitude().to_string();
full_params.push(("latitude", latitude.as_str()));
let longitude = settings.get_longitude().to_string();
full_params.push(("longitude", longitude.as_str()));
full_params.push(("year", params.year.as_str()));
full_params.push(("month", params.month.as_str()));
full_params.push(("day", params.day.as_str()));
full_params.push(("hour", params.hour.as_str()));
full_params.push(("minute", params.minute.as_str()));
full_params.push(("duration", params.duration.as_str()));
full_params.push(("max_objects", params.max_objects.as_str()));
full_params.push(("min_alt", params.min_alt.as_str()));
full_params.push(("solar_elong", params.solar_elong.as_str()));
full_params.push(("lunar_elong", params.lunar_elong.as_str()));
full_params.push(("object_type", params.object_type.as_str()));
full_params.push(("submit", "Submit"));
let url = reqwest::Url::parse_with_params(
"https://www.minorplanetcenter.net/whatsup/index",
full_params,
)
.context("Failed to create MPC URL")?;
let client = mpc_http_client()?;
let response = client
.post(url)
.send()
.context("Failed to send request to MPC")?
.text()
.context("Failed to read MPC response")?;
Ok(response)
}
pub fn parse_whats_up_html(
html: &str,
params: &WhatsUpParams,
observatory: &Observatory,
) -> Result<Vec<PossibleTarget>> {
let mut objects: Vec<PossibleTarget> = Vec::new();
let document = scraper::Html::parse_document(html);
let table_item_selector = scraper::Selector::parse("td")
.map_err(|e| anyhow!("Failed to parse table item selector: {:?}", e))?;
let rows_selector = scraper::Selector::parse("#main table tr")
.map_err(|e| anyhow!("Failed to parse rows selector: {:?}", e))?;
let designation_link_selector = scraper::Selector::parse("td a[href*='show_object']")
.map_err(|e| anyhow!("Failed to parse designation link selector: {:?}", e))?;
let date = Utc
.with_ymd_and_hms(
params.year.parse().context("Failed to parse year")?,
params.month.parse().context("Failed to parse month")?,
params.day.parse().context("Failed to parse day")?,
params.hour.parse().context("Failed to parse hour")?,
params.minute.parse().context("Failed to parse minute")?,
0,
)
.single()
.context("Invalid date/time")?;
for row in document.select(&rows_selector) {
if row.select(&designation_link_selector).next().is_none() {
continue;
}
let cells: Vec<scraper::ElementRef<'_>> = row.select(&table_item_selector).collect();
match create_possible_target(cells) {
Ok(object) => {
if is_visible_with_observatory(
&object.ra.replace(' ', ":"),
&object.dec.replace(' ', ":"),
date,
observatory,
) {
objects.push(object);
}
}
Err(e) => {
eprintln!("Warning: Failed to create object: {}", e);
}
}
}
Ok(objects)
}
pub fn parse_whats_up_response(params: &WhatsUpParams) -> Result<Vec<PossibleTarget>> {
let settings = Settings::new().map_err(|e| anyhow!("Failed to load settings: {}", e))?;
let data = get_observing_target_list(params)?;
parse_whats_up_html(&data, params, &settings.observatory)
}
fn create_possible_target(item: Vec<scraper::ElementRef<'_>>) -> Result<PossibleTarget> {
let mut possible_target = PossibleTarget::default();
if item.len() < 8 {
return Err(anyhow!("Not enough elements in input vector"));
}
let designation_selector =
scraper::Selector::parse("a").map_err(|e| anyhow!("Failed to parse selector: {}", e))?;
let designation = item[table_indices::DESIGNATION]
.select(&designation_selector)
.next()
.ok_or_else(|| anyhow!("Designation element not found"))?;
possible_target.designation = designation.inner_html();
possible_target.magnitude = item[table_indices::MAGNITUDE]
.inner_html()
.parse::<f32>()
.map_err(|e| anyhow!("Failed to parse magnitude: {}", e))?;
possible_target.altitude = item[table_indices::BEG_ALT]
.inner_html()
.replace(' ', "")
.parse::<f32>()
.map_err(|e| anyhow!("Failed to parse altitude: {}", e))?;
possible_target.ra = item[table_indices::BEG_RA].inner_html();
possible_target.dec = item[table_indices::BEG_DEC].inner_html();
Ok(possible_target)
}
#[cfg(test)]
mod test {
use super::*;
fn fixture_observatory() -> Observatory {
Observatory {
place: "La Spezia".to_string(),
latitude: 44.09727,
longitude: 9.7737,
altitude: 200.0,
observatory_name: "Test".to_string(),
observer_name: "Test".to_string(),
mpc_code: "123".to_string(),
north_altitude: 10,
south_altitude: 10,
east_altitude: 10,
west_altitude: 10,
}
}
fn fixture_whats_up_params() -> WhatsUpParams {
WhatsUpParams {
year: "2025".to_string(),
month: "1".to_string(),
day: "15".to_string(),
hour: "0".to_string(),
minute: "0".to_string(),
duration: "1".to_string(),
max_objects: "10".to_string(),
min_alt: "10".to_string(),
solar_elong: "0".to_string(),
lunar_elong: "0".to_string(),
object_type: "mp".to_string(),
}
}
#[test]
fn test_extract_authenticity_token_from_example_html() {
let html = include_str!("../response_examples/whatsup.html");
let token = extract_authenticity_token_from_html(html);
assert_eq!(token, "6jL1Ruhw/ENf7P8I7VSi5YgwcNKf8+8ps2vvYtjf/Us=");
}
#[test]
fn test_parse_whats_up_html_from_fixture() {
let html = include_str!("../response_examples/whatsup.html");
let params = fixture_whats_up_params();
let observatory = fixture_observatory();
let objects = parse_whats_up_html(html, ¶ms, &observatory).unwrap();
assert!(!objects.is_empty());
assert!(
objects
.iter()
.any(|o| o.designation.contains("Eunomia"))
);
let eunomia = objects
.iter()
.find(|o| o.designation.contains("Eunomia"))
.unwrap();
assert!((eunomia.magnitude - 9.0).abs() < f32::EPSILON);
}
#[cfg(feature = "network-tests")]
#[test]
fn test_get_observing_target_list_live() {
let result = get_observing_target_list(&WhatsUpParams::default());
assert!(result.is_ok());
assert!(result.unwrap().contains("Designation"));
}
#[cfg(feature = "network-tests")]
#[test]
fn test_parse_whats_up_response_live() {
let result = parse_whats_up_response(&WhatsUpParams::default());
assert!(result.is_ok());
}
}