use crate::error::{DatapassError, Result};
use crate::types::DataUsage;
use scraper::{Html, Selector};
pub fn parse_html(html: &str) -> Result<DataUsage> {
let document = Html::parse_document(html);
if is_auth_required_page(&document) {
return Err(DatapassError::DataNotFound(
"Access denied. This website requires an active Telekom mobile data connection.\n \
\nTo test without Telekom network, use: --file <saved-html-file>"
.to_string(),
));
}
let plan_name = extract_plan_name(&document)?;
let valid_until = extract_valid_until(&document);
if is_unlimited_plan(&document) {
return Ok(DataUsage::new_unlimited(Some(plan_name), valid_until));
}
let (remaining_gb, total_gb) = extract_data_usage(&document)?;
Ok(DataUsage::new(
remaining_gb,
total_gb,
Some(plan_name),
valid_until,
))
}
fn extract_plan_name(document: &Html) -> Result<String> {
let title_selector = Selector::parse("title")
.map_err(|e| DatapassError::ParseError(format!("Invalid selector: {:?}", e)))?;
let title = document
.select(&title_selector)
.next()
.ok_or_else(|| DatapassError::DataNotFound("Title not found".to_string()))?
.text()
.collect::<String>();
let plan_name = title
.split('-')
.nth(1)
.map(|s| {
s.replace('\u{00A0}', " ").trim().to_string()
})
.ok_or_else(|| {
DatapassError::ParseError("Could not extract plan name from title".to_string())
})?;
Ok(plan_name)
}
fn is_unlimited_plan(document: &Html) -> bool {
let section_selector = match Selector::parse("section.data-pass-instance") {
Ok(s) => s,
Err(_) => return false,
};
let volume_selector = match Selector::parse("div.volume") {
Ok(s) => s,
Err(_) => return false,
};
for section in document.select(§ion_selector) {
if let Some(id) = section.value().attr("id") {
if id == "summationPass" {
continue;
}
}
for volume_elem in section.select(&volume_selector) {
let text = volume_elem.text().collect::<String>().to_lowercase();
if text.contains("unlimited") || text.contains("unbegrenzt") {
return true;
}
}
}
false
}
fn extract_data_usage(document: &Html) -> Result<(f64, f64)> {
let section_selector = Selector::parse("section.data-pass-instance")
.map_err(|e| DatapassError::ParseError(format!("Invalid selector: {:?}", e)))?;
let remaining_selector = Selector::parse("div.remaining-volume-value")
.map_err(|e| DatapassError::ParseError(format!("Invalid selector: {:?}", e)))?;
let total_selector = Selector::parse("div.start-volume")
.map_err(|e| DatapassError::ParseError(format!("Invalid selector: {:?}", e)))?;
for section in document.select(§ion_selector) {
if let Some(id) = section.value().attr("id") {
if id == "summationPass" {
continue;
}
}
let remaining_text = section
.select(&remaining_selector)
.next()
.map(|elem| elem.text().collect::<String>().trim().to_string());
let total_text = section
.select(&total_selector)
.next()
.map(|elem| elem.text().collect::<String>().trim().to_string());
if let (Some(remaining), Some(total)) = (remaining_text, total_text) {
let remaining_gb: f64 = parse_number(&remaining)?;
let total_gb: f64 = parse_number(&total)?;
return Ok((remaining_gb, total_gb));
}
}
Err(DatapassError::DataNotFound(
"Could not find data usage information".to_string(),
))
}
fn extract_valid_until(document: &Html) -> Option<String> {
let info_row_selector = Selector::parse("div.info-row").ok()?;
for elem in document.select(&info_row_selector) {
let text = elem.text().collect::<String>();
if text.contains("Valid until:") || text.contains("Gültig bis:") {
let date = text.split(':').nth(1).map(|s| s.trim().to_string())?;
return Some(date);
}
}
None
}
fn is_auth_required_page(document: &Html) -> bool {
let body_text = document
.root_element()
.text()
.collect::<String>()
.to_lowercase();
body_text.contains("direkter zugriff")
|| body_text.contains("direct access to the page not possible")
|| (body_text.contains("weiterleitung") && body_text.contains("nicht möglich"))
}
fn parse_number(s: &str) -> Result<f64> {
let normalized = s.replace(',', ".");
normalized
.parse()
.map_err(|e| DatapassError::ParseError(format!("Invalid number value '{}': {}", s, e)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_german_numbers() {
let html = r#"
<!DOCTYPE html>
<html>
<head>
<title>Datennutzung - MagentaMobil Prepaid XL</title>
</head>
<body>
<section class="data-pass-instance" id="test-pass">
<div class="remaining-volume-value">38,36</div>
<div class="start-volume">51</div>
</section>
</body>
</html>
"#;
let result = parse_html(html);
assert!(
result.is_ok(),
"Failed to parse German format HTML: {:?}",
result.err()
);
let data = result.unwrap();
assert_eq!(data.remaining_gb, 38.36);
assert_eq!(data.total_gb, 51.0);
assert_eq!(data.plan_name, Some("MagentaMobil Prepaid XL".to_string()));
}
#[test]
#[ignore = "Requires test file not available in Nix build"]
fn test_parse_test_html() {
let html = std::fs::read_to_string("test/Data usage - MagentaMobil Prepaid L.html")
.expect("Failed to read test file");
let result = parse_html(&html).expect("Failed to parse HTML");
assert_eq!(result.remaining_gb, 2.88);
assert_eq!(result.total_gb, 25.0);
assert_eq!(result.used_gb, 22.12);
assert_eq!(result.plan_name, Some("MagentaMobil Prepaid L".to_string()));
assert!((result.percentage - 88.48).abs() < 0.1);
assert!((result.remaining_percentage() - 11.52).abs() < 0.1);
}
#[test]
fn test_parse_unlimited_plan() {
let html = r#"
<!DOCTYPE html>
<html>
<head>
<title>Data usage - MagentaMobil Prepaid Max</title>
</head>
<body>
<section class="data-pass-instance" id="pass-42213a5dad80000a">
<div class="ribbon">
<strong>active data pass</strong>
</div>
<div class="data-pass-instance__headline">
<h2>MagentaMobil Prepaid Max</h2>
</div>
<div class="sub-headline">Your remaining data volume</div>
<div class="volume fit-text-to-container">
<strong>unlimited</strong>
</div>
<div class="info-row">Valid until: 27. February 2026</div>
</section>
</body>
</html>
"#;
let result = parse_html(html);
assert!(
result.is_ok(),
"Failed to parse unlimited plan HTML: {:?}",
result.err()
);
let data = result.unwrap();
assert!(data.is_unlimited, "Plan should be marked as unlimited");
assert_eq!(data.plan_name, Some("MagentaMobil Prepaid Max".to_string()));
assert_eq!(data.valid_until, Some("27. February 2026".to_string()));
}
#[test]
fn test_parse_unlimited_plan_german() {
let html = r#"
<!DOCTYPE html>
<html>
<head>
<title>Datennutzung - MagentaMobil Prepaid Max</title>
</head>
<body>
<section class="data-pass-instance" id="summationPass">
<strong><span class="volume">unbegrenzt</span></strong>
</section>
<section class="data-pass-instance" id="pass-test">
<div class="data-pass-instance__headline">
<h2>MagentaMobil Prepaid Max</h2>
</div>
<div class="volume fit-text-to-container">
<strong>unbegrenzt</strong>
</div>
</section>
</body>
</html>
"#;
let result = parse_html(html);
assert!(
result.is_ok(),
"Failed to parse German unlimited plan HTML: {:?}",
result.err()
);
let data = result.unwrap();
assert!(data.is_unlimited, "Plan should be marked as unlimited");
assert_eq!(data.plan_name, Some("MagentaMobil Prepaid Max".to_string()));
}
}