use crate::chart::{Chart, EventType};
use crate::luna::USER_AGENT;
use reqwest::blocking::Client;
use scraper::{Html, Selector};
use std::time::Duration;
pub const ASTRO_URL: &str = "https://www.astro.com";
pub const LOGIN_PAGE: &str = "https://www.astro.com/cgi/scus.cgi?act=lgi";
pub const LOGIN_POST: &str = "https://www.astro.com/cgi/scus.cgi";
pub const AWD_URL: &str = "https://www.astro.com/cgi/awd.cgi";
#[derive(Debug, thiserror::Error)]
pub enum AstroError {
#[error("HTTP: {0}")]
Http(#[from] reqwest::Error),
#[error("HTTP client build error: {0}")]
HttpClientBuild(String),
#[error("login failed — check credentials (final URL: {0})")]
LoginFailed(String),
#[error("no <pre> block in AAF response — session cookie may be invalid")]
AafNotFound,
#[error("could not extract nhor ID from create response")]
NhorNotFound,
#[error("could not find unid_token in listing page")]
UnidTokenNotFound,
#[error("delete failed — nhor IDs still present: {0:?}")]
DeleteVerifyFailed(Vec<u32>),
#[error("AAF parse error: {0}")]
AafParse(String),
}
pub struct AstroSession {
client: Client,
cid: String,
delay: Duration,
}
impl AstroSession {
fn build_client(cid: &str) -> Result<Client, AstroError> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::COOKIE,
reqwest::header::HeaderValue::from_str(&format!("cid={cid}"))
.map_err(|e| AstroError::HttpClientBuild(e.to_string()))?,
);
headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static(
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
),
);
headers.insert(
reqwest::header::ACCEPT_LANGUAGE,
reqwest::header::HeaderValue::from_static("en-US,en;q=0.9"),
);
Client::builder()
.default_headers(headers)
.user_agent(USER_AGENT)
.http1_only()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| AstroError::HttpClientBuild(e.to_string()))
}
pub fn from_cid(cid: &str, delay_ms: u64) -> Result<Self, AstroError> {
Ok(Self {
client: Self::build_client(cid)?,
cid: cid.to_string(),
delay: Duration::from_millis(delay_ms),
})
}
pub fn login(email: &str, pass: &str, delay_ms: u64) -> Result<Self, AstroError> {
let anon_client = Client::builder()
.user_agent(USER_AGENT)
.http1_only()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| AstroError::HttpClientBuild(e.to_string()))?;
let page_html = anon_client
.get(LOGIN_PAGE)
.send()?
.error_for_status()?
.text()?;
let temp_cid = {
let doc = Html::parse_document(&page_html);
let sel = Selector::parse(r#"input[name="cid"]"#).unwrap();
doc.select(&sel)
.next()
.and_then(|n| n.value().attr("value"))
.unwrap_or("")
.to_string()
};
let payload = login_payload(email, pass, &temp_cid);
let resp = anon_client
.post(LOGIN_POST)
.form(&payload)
.send()?
.error_for_status()?;
let final_url = resp.url().as_str().to_string();
let body = resp.text()?;
let cid = if let Some(c) = extract_cid_from_url(&final_url) {
c.to_string()
} else {
let doc = Html::parse_document(&body);
let sel = Selector::parse(r#"input[name="cid"]"#).unwrap();
doc.select(&sel)
.next()
.and_then(|n| n.value().attr("value"))
.filter(|v| !v.is_empty() && v.contains('-'))
.map(str::to_string)
.ok_or_else(|| AstroError::LoginFailed(final_url))?
};
Self::from_cid(&cid, delay_ms)
}
fn get_text(&self, url: &str) -> Result<String, AstroError> {
Ok(self.client.get(url).send()?.error_for_status()?.text()?)
}
fn sleep(&self) {
if !self.delay.is_zero() {
std::thread::sleep(self.delay);
}
}
#[must_use]
pub fn cid(&self) -> &str {
&self.cid
}
pub fn fetch_charts(&self) -> Result<(Vec<crate::chart::Chart>, Vec<u32>), AstroError> {
use crate::normalize::normalize_chart;
use std::collections::HashMap;
let list_html = self.get_text(&format!("{AWD_URL}?lang=e"))?;
let listing = parse_listing(&list_html);
let name_to_nhor: HashMap<String, u32> = listing
.into_iter()
.map(|l| (l.name.to_lowercase(), l.nhor_id))
.collect();
self.sleep();
let aaf_html = self.get_text(&format!("{AWD_URL}?lang=e&act=aaf"))?;
let aaf_text = extract_aaf(&aaf_html).ok_or(AstroError::AafNotFound)?;
let charts =
crate::aaf::parse_file(&aaf_text).map_err(|e| AstroError::AafParse(e.to_string()))?;
let (charts_out, nhor_ids_out) = charts
.into_iter()
.map(|mut chart| {
normalize_chart(&mut chart);
let name_lc = chart.name.to_lowercase();
let id = name_to_nhor
.get(&name_lc)
.or_else(|| {
if let Some(pos) = chart.name.find(", ") {
name_to_nhor.get(&chart.name[pos + 2..].to_lowercase())
} else {
None
}
})
.copied()
.unwrap_or(0);
(chart, id)
})
.unzip();
Ok((charts_out, nhor_ids_out))
}
pub fn write_charts(
&self,
charts: &[crate::chart::Chart],
nhor_ids: &[u32],
on_start: &dyn Fn(usize, usize, &str),
on_result: &dyn Fn(&str),
) -> Result<(), AstroError> {
let new_charts: Vec<_> = charts
.iter()
.zip(nhor_ids.iter())
.filter(|(_, id)| **id == 0)
.collect();
let total = new_charts.len();
for (i, (chart, _)) in new_charts.iter().enumerate() {
on_start(
i + 1,
total,
&chart.name.chars().take(40).collect::<String>(),
);
match self.create_one(chart) {
Ok(id) => on_result(&format!("created nhor={id}")),
Err(e) => on_result(&format!("[!] create: {e}")),
}
self.sleep();
}
Ok(())
}
pub fn create_one(&self, chart: &crate::chart::Chart) -> Result<u32, AstroError> {
let form_url = format!("{ASTRO_URL}/cgi/ade.cgi?lang=e");
let post_url = format!("{ASTRO_URL}/cgi/ade.cgi");
let form_html = self.get_text(&form_url)?;
let sprev = extract_sprev(&form_html).unwrap_or_default();
let payload = create_payload(chart, &self.cid, &sprev);
let resp = self
.client
.post(&post_url)
.form(&payload)
.send()?
.error_for_status()?;
let final_url = resp.url().as_str().to_string();
let body = resp.text()?;
let spli_opts = extract_spli_options(&body);
if !spli_opts.is_empty() {
let updated_sprev = extract_sprev(&body).unwrap_or_else(|| sprev.clone());
let mut sel = create_payload(chart, &self.cid, &updated_sprev);
sel.push(("spli".into(), spli_opts[0].clone()));
let resp2 = self
.client
.post(&post_url)
.form(&sel)
.send()?
.error_for_status()?;
let url2 = resp2.url().as_str().to_string();
let body2 = resp2.text()?;
return parse_nhor_from_url(&url2)
.or_else(|| parse_nhor_from_url(&body2))
.ok_or(AstroError::NhorNotFound);
}
parse_nhor_from_url(&final_url)
.or_else(|| parse_nhor_from_url(&body))
.ok_or(AstroError::NhorNotFound)
}
pub fn delete_charts(
&self,
email: &str,
pass: &str,
nhor_ids: &[u32],
) -> Result<(), AstroError> {
let listing_html = self.get_text(&format!("{AWD_URL}?lang=e"))?;
let token = extract_unid_token(&listing_html).ok_or(AstroError::UnidTokenNotFound)?;
let mut params = format!(
"{AWD_URL}?act=del&conf=1&lang=e&unid_token={}",
urlencoding_simple(&token)
);
for &id in nhor_ids {
use std::fmt::Write;
write!(params, "&del{id}=on").unwrap();
}
self.get_text(¶ms)?;
self.sleep();
let payload = delete_payload(email, pass, nhor_ids);
self.client
.post(AWD_URL)
.form(&payload)
.send()?
.error_for_status()?;
self.sleep();
let listing2 = self.get_text(&format!("{AWD_URL}?lang=e"))?;
let remaining = parse_listing(&listing2);
let still_present: Vec<u32> = nhor_ids
.iter()
.filter(|&&id| remaining.iter().any(|l| l.nhor_id == id))
.copied()
.collect();
if still_present.is_empty() {
Ok(())
} else {
Err(AstroError::DeleteVerifyFailed(still_present))
}
}
}
fn urlencoding_simple(s: &str) -> String {
s.chars()
.map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
_ => format!("%{:02X}", c as u32),
})
.collect()
}
#[must_use]
pub fn extract_cid_from_url(url: &str) -> Option<&str> {
let start = url.find("cid=")? + "cid=".len();
let rest = &url[start..];
let end = rest.find(['&', ';', ' ']).unwrap_or(rest.len());
let val = &rest[..end];
if val.is_empty() { None } else { Some(val) }
}
#[must_use]
pub fn login_payload(email: &str, pass: &str, temp_cid: &str) -> Vec<(String, String)> {
vec![
("eml".into(), email.to_string()),
("eml1".into(), email.to_string()),
("pwd".into(), pass.to_string()),
("tit".into(), String::new()),
("fnm".into(), String::new()),
("nam".into(), String::new()),
("ctr".into(), String::new()),
("lan".into(), String::new()),
("sec".into(), String::new()),
("lang".into(), "e".to_string()),
("cid".into(), temp_cid.to_string()),
("submit".into(), "Login".to_string()),
]
}
#[must_use]
pub fn extract_unid_token(html: &str) -> Option<String> {
let start = html.find("unid_token")?;
let quote_start = html[start..].find('\'')? + start + 1;
let quote_end = html[quote_start..].find('\'')? + quote_start;
let token = &html[quote_start..quote_end];
if token.is_empty() {
None
} else {
Some(token.to_string())
}
}
#[must_use]
pub fn delete_payload(email: &str, password: &str, nhor_ids: &[u32]) -> Vec<(String, String)> {
let mut fields = vec![
("act".into(), "del".into()),
("mail".into(), email.to_string()),
("pwrd".into(), password.to_string()),
("delnow".into(), "Yes".into()),
];
for &id in nhor_ids {
fields.push((format!("del{id}"), "on".into()));
}
fields
}
pub struct AstroListing {
pub nhor_id: u32,
pub name: String,
}
#[must_use]
pub fn parse_listing(html: &str) -> Vec<AstroListing> {
let doc = Html::parse_document(html);
let Ok(sel) = Selector::parse("a") else {
return Vec::new();
};
doc.select(&sel)
.filter_map(|a| {
let href = a.value().attr("href")?;
if !href.contains("/cgi/ade.cgi") {
return None;
}
let nhor_id = parse_nhor_from_url(href)?;
let title = a.value().attr("title")?;
let name = title
.trim()
.strip_prefix("edit birth data for ")?
.trim()
.to_string();
if name.is_empty() {
return None;
}
Some(AstroListing { nhor_id, name })
})
.collect()
}
pub fn extract_sprev(html: &str) -> Option<String> {
let doc = Html::parse_document(html);
let sel = Selector::parse(r#"input[name="sprev"]"#).ok()?;
doc.select(&sel)
.next()?
.value()
.attr("value")
.map(String::from)
}
#[must_use]
pub fn extract_spli_options(html: &str) -> Vec<String> {
let doc = Html::parse_document(html);
let Ok(sel) = Selector::parse(r#"select[name="spli"] option"#) else {
return Vec::new();
};
doc.select(&sel)
.filter_map(|opt| opt.value().attr("value").map(String::from))
.collect()
}
#[must_use]
pub fn extract_aaf(html: &str) -> Option<String> {
let doc = Html::parse_document(html);
let sel = Selector::parse("pre").ok()?;
let pre = doc.select(&sel).next()?;
Some(pre.text().collect())
}
#[must_use]
pub fn parse_nhor_from_url(url: &str) -> Option<u32> {
let start = url.find("nhor=")? + "nhor=".len();
url[start..]
.split(['&', ';', ' ', '#'])
.next()?
.parse()
.ok()
}
#[must_use]
pub fn offset_to_szon(offset: f64, is_lmt: bool) -> String {
if is_lmt {
return "lmt".to_string();
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let total_mins = (offset.abs() * 60.0).round() as u32;
let hours = total_mins / 60;
let mins = total_mins % 60;
let hemi = if offset < 0.0 { 'w' } else { 'e' };
format!("h{hours}{hemi}{mins:02}")
}
#[must_use]
pub fn create_payload(chart: &Chart, cid: &str, sprev: &str) -> Vec<(String, String)> {
let (last, first) = split_name(&chart.name);
let ssx = match chart.event_type {
EventType::Male => "m",
EventType::Female => "f",
_ => "e",
};
let szon = offset_to_szon(chart.tz_offset_hours, chart.is_lmt);
vec![
("sfnm".into(), first),
("snam".into(), last),
("ssx".into(), ssx.into()),
("sday".into(), chart.day.to_string()),
("imon".into(), chart.month.to_string()),
("syar".into(), chart.year.to_string()),
("ihou".into(), chart.hour.to_string()),
("smin".into(), chart.minute.to_string()),
("scit".into(), chart.city.clone().unwrap_or_default()),
("szon".into(), szon),
("lang".into(), "e".into()),
("extset".into(), "close".into()),
("btyp".into(), "w2at".into()),
("sprev".into(), sprev.to_string()),
("cid".into(), cid.into()),
("subcon".into(), "continue".into()),
]
}
#[must_use]
pub fn edit_payload(chart: &Chart, nhor_id: u32, cid: &str) -> Vec<(String, String)> {
let (last, first) = split_name(&chart.name);
let ssx = match chart.event_type {
EventType::Male => "m",
EventType::Female => "f",
_ => "e",
};
let szon = offset_to_szon(chart.tz_offset_hours, chart.is_lmt);
vec![
("sfnm".into(), first),
("snam".into(), last),
("ssx".into(), ssx.into()),
("sday".into(), chart.day.to_string()),
("imon".into(), chart.month.to_string()),
("syar".into(), chart.year.to_string()),
("ihou".into(), chart.hour.to_string()),
("smin".into(), chart.minute.to_string()),
("scit".into(), chart.city.clone().unwrap_or_default()),
("szon".into(), szon),
("lang".into(), "e".into()),
("extset".into(), "close".into()),
("btyp".into(), "w2at".into()),
("cid".into(), cid.into()),
("nhor".into(), nhor_id.to_string()),
("subcon".into(), "continue".into()),
]
}
fn split_name(name: &str) -> (String, String) {
if let Some(pos) = name.find(',') {
let last = name[..pos].trim().to_string();
let first = name[pos + 1..].trim().to_string();
(last, first)
} else {
(String::new(), name.trim().to_string())
}
}