use serde_json::Value;
use crate::error::{Error, Result};
use crate::page::Page;
const GEO_API_URL: &str = "https://ipwho.is/";
const GEO_TRACE_URL: &str = "https://www.cloudflare.com/cdn-cgi/trace";
#[derive(Debug, Clone)]
pub(super) struct GeoInfo {
pub(super) timezone: Option<String>,
pub(super) country: String,
}
fn parse_geo_api(text: &str) -> Option<GeoInfo> {
let value: Value = serde_json::from_str(text).ok()?;
let timezone = value.pointer("/timezone/id")?.as_str()?.to_string();
let country = value.get("country_code")?.as_str()?.to_string();
if timezone.is_empty()
|| country.len() != 2
|| !country.bytes().all(|byte| byte.is_ascii_alphabetic())
{
return None;
}
Some(GeoInfo {
timezone: Some(timezone),
country: country.to_uppercase(),
})
}
fn parse_geo_trace(text: &str) -> Option<GeoInfo> {
let mut country = None;
for line in text.lines() {
if let Some(value) = line.strip_prefix("loc=") {
country = Some(value.trim().to_string());
}
}
let country = country.filter(|c| c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic()))?;
Some(GeoInfo {
timezone: None,
country: country.to_uppercase(),
})
}
pub(super) fn languages_for_country(country: &str) -> Vec<String> {
let languages: &[&str] = match country {
"US" => &["en-US", "en"],
"GB" => &["en-GB", "en"],
"AU" | "NZ" => &["en-AU", "en"],
"CA" => &["en-CA", "fr-CA", "en", "fr"],
"IE" => &["en-IE", "en"],
"IN" => &["en-IN", "hi-IN", "en", "hi"],
"DE" | "AT" => &["de-DE", "de"],
"CH" => &["de-CH", "fr-CH", "it-CH", "de", "fr", "it"],
"FR" | "LU" => &["fr-FR", "fr"],
"BE" => &["nl-BE", "fr-BE", "nl", "fr"],
"ES" | "MX" | "AR" | "CL" | "CO" => &["es-ES", "es"],
"BR" => &["pt-BR", "pt"],
"PT" => &["pt-PT", "pt"],
"IT" => &["it-IT", "it"],
"NL" => &["nl-NL", "nl"],
"SE" => &["sv-SE", "sv"],
"NO" => &["nb-NO", "no"],
"DK" => &["da-DK", "da"],
"FI" => &["fi-FI", "fi"],
"PL" => &["pl-PL", "pl"],
"CZ" | "SK" => &["cs-CZ", "cs"],
"HU" => &["hu-HU", "hu"],
"RO" => &["ro-RO", "ro"],
"GR" => &["el-GR", "el"],
"TR" => &["tr-TR", "tr"],
"RU" | "BY" | "KZ" => &["ru-RU", "ru"],
"UA" => &["uk-UA", "ru", "uk"],
"JP" => &["ja-JP", "ja"],
"KR" => &["ko-KR", "ko"],
"CN" => &["zh-CN", "zh"],
"TW" | "HK" => &["zh-TW", "zh"],
"VN" => &["vi-VN", "vi"],
"TH" => &["th-TH", "th"],
"ID" => &["id-ID", "id"],
"SA" | "AE" | "EG" => &["ar-SA", "ar"],
"IL" => &["he-IL", "he"],
_ => &["en-US", "en"],
};
languages.iter().map(|s| (*s).to_string()).collect()
}
async fn read_remote_text(page: &Page, url: &str) -> Result<String> {
page.goto(url).await?;
page.text().await
}
pub(super) async fn lookup(page: &Page) -> Result<GeoInfo> {
if let Some(geo) = read_remote_text(page, GEO_API_URL)
.await
.ok()
.and_then(|text| parse_geo_api(&text))
{
return Ok(geo);
}
let text = read_remote_text(page, GEO_TRACE_URL).await?;
parse_geo_trace(&text).ok_or_else(|| Error::Launch("geo response missing loc".into()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_geo_api_response() {
let body = r#"{"ip":"203.0.113.9","country_code":"DE","timezone":{"id":"Europe/Berlin","abbr":"CEST"}}"#;
let geo = parse_geo_api(body).expect("valid geo api response");
assert_eq!(geo.timezone.as_deref(), Some("Europe/Berlin"));
assert_eq!(geo.country, "DE");
assert!(parse_geo_api("not json").is_none());
assert!(parse_geo_api(r#"{"success":false,"message":"rate limited"}"#).is_none());
}
#[test]
fn parses_geo_trace_response() {
let trace = "fl=123\n\nip=203.0.113.9\nloc=DE\nsnf=oia\n";
let geo = parse_geo_trace(trace).expect("valid trace");
assert_eq!(geo.timezone, None);
assert_eq!(geo.country, "DE");
}
#[test]
fn rejects_malformed_geo_trace() {
assert!(parse_geo_trace("fl=1\nip=1.2.3.4\n").is_none());
assert!(parse_geo_trace("loc=\n").is_none());
assert!(parse_geo_trace("loc=DEU\n").is_none());
assert!(parse_geo_trace("").is_none());
}
#[test]
fn languages_match_ip_country() {
assert_eq!(languages_for_country("DE"), vec!["de-DE", "de"]);
assert_eq!(languages_for_country("JP"), vec!["ja-JP", "ja"]);
assert_eq!(
languages_for_country("CA"),
vec!["en-CA", "fr-CA", "en", "fr"]
);
assert_eq!(languages_for_country("ZZ"), vec!["en-US", "en"]);
}
}