use std::time::{Duration, Instant};
use serde_json::Value;
use crate::browser::ContextOverride;
use crate::launcher::Proxy;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ProxyGeo {
pub ip: Option<String>,
pub country_code: Option<String>,
pub timezone: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
}
impl ProxyGeo {
pub fn from_ipapi_json(v: &Value) -> Self {
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
let f = |k: &str| v.get(k).and_then(Value::as_f64);
Self {
ip: s("query").or_else(|| s("ip")),
country_code: s("countryCode").or_else(|| s("country_code")),
timezone: s("timezone"),
latitude: f("lat").or_else(|| f("latitude")),
longitude: f("lon").or_else(|| f("longitude")),
}
}
pub fn coherent_override(&self) -> ContextOverride {
let mut ov = ContextOverride::new();
if let Some(tz) = &self.timezone {
ov = ov.timezone(tz.clone());
}
if let (Some(lat), Some(lon)) = (self.latitude, self.longitude) {
ov = ov.geolocation(lat, lon);
}
if let Some(loc) = self.country_code.as_deref().and_then(locale_for_country) {
ov = ov.locale(loc);
}
ov
}
}
#[derive(Debug, Clone, Default)]
pub struct ProxyHealth {
pub healthy: Option<bool>,
pub latency_ms: Option<u64>,
pub geo: ProxyGeo,
pub error: Option<String>,
}
impl ProxyHealth {
pub fn usable(&self) -> bool {
self.healthy != Some(false)
}
}
pub fn locale_for_country(cc: &str) -> Option<&'static str> {
let v = match cc.to_ascii_uppercase().as_str() {
"US" => "en-US",
"GB" | "UK" => "en-GB",
"CA" => "en-CA",
"AU" => "en-AU",
"CN" => "zh-CN",
"TW" => "zh-TW",
"HK" => "zh-HK",
"JP" => "ja-JP",
"KR" => "ko-KR",
"DE" => "de-DE",
"FR" => "fr-FR",
"ES" => "es-ES",
"IT" => "it-IT",
"RU" => "ru-RU",
"BR" => "pt-BR",
"PT" => "pt-PT",
"NL" => "nl-NL",
"SE" => "sv-SE",
"PL" => "pl-PL",
"TR" => "tr-TR",
"IN" => "en-IN",
"ID" => "id-ID",
"VN" => "vi-VN",
"TH" => "th-TH",
"MY" => "ms-MY",
"SG" => "en-SG",
"MX" => "es-MX",
"AR" => "es-AR",
"SA" => "ar-SA",
"AE" => "ar-AE",
"UA" => "uk-UA",
_ => return None,
};
Some(v)
}
pub async fn probe_proxy(proxy: &Proxy, check_url: &str, timeout: Duration) -> ProxyHealth {
let started = Instant::now();
match probe_inner(proxy, check_url, timeout).await {
Ok(geo) => ProxyHealth {
healthy: Some(true),
latency_ms: Some(started.elapsed().as_millis() as u64),
geo,
error: None,
},
Err(e) => ProxyHealth {
healthy: Some(false),
latency_ms: None,
geo: ProxyGeo::default(),
error: Some(e),
},
}
}
async fn probe_inner(
proxy: &Proxy,
check_url: &str,
timeout: Duration,
) -> Result<ProxyGeo, String> {
let mut rp = reqwest::Proxy::all(&proxy.server).map_err(|e| format!("代理地址非法: {e}"))?;
if let (Some(u), Some(p)) = (&proxy.username, &proxy.password) {
rp = rp.basic_auth(u, p);
}
let client = reqwest::Client::builder()
.proxy(rp)
.timeout(timeout)
.build()
.map_err(|e| format!("构建客户端失败(socks5 需启用 reqwest 的 socks 特性): {e}"))?;
let resp = client
.get(check_url)
.send()
.await
.map_err(|e| format!("请求失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status().as_u16()));
}
let body = resp
.text()
.await
.map_err(|e| format!("读取响应失败: {e}"))?;
let v: Value = serde_json::from_str(&body).map_err(|e| format!("响应非 JSON: {e}"))?;
if v.get("status").and_then(Value::as_str) == Some("fail") {
return Err(v
.get("message")
.and_then(Value::as_str)
.unwrap_or("探测端点返回 fail")
.to_string());
}
Ok(ProxyGeo::from_ipapi_json(&v))
}
pub const DEFAULT_CHECK_URL: &str = "http://ip-api.com/json";
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn locale_mapping_common() {
assert_eq!(locale_for_country("US"), Some("en-US"));
assert_eq!(locale_for_country("cn"), Some("zh-CN")); assert_eq!(locale_for_country("JP"), Some("ja-JP"));
assert_eq!(locale_for_country("ZZ"), None);
}
#[test]
fn parse_ipapi_geo() {
let v = json!({
"status": "success", "query": "1.2.3.4", "countryCode": "US",
"timezone": "America/New_York", "lat": 40.71, "lon": -74.0
});
let g = ProxyGeo::from_ipapi_json(&v);
assert_eq!(g.ip.as_deref(), Some("1.2.3.4"));
assert_eq!(g.country_code.as_deref(), Some("US"));
assert_eq!(g.timezone.as_deref(), Some("America/New_York"));
assert_eq!(g.latitude, Some(40.71));
assert_eq!(g.longitude, Some(-74.0));
}
#[test]
fn coherent_override_from_geo() {
let g = ProxyGeo {
ip: Some("1.2.3.4".into()),
country_code: Some("JP".into()),
timezone: Some("Asia/Tokyo".into()),
latitude: Some(35.68),
longitude: Some(139.69),
};
let ov = g.coherent_override();
assert_eq!(ov.timezone_id.as_deref(), Some("Asia/Tokyo"));
assert_eq!(ov.locale.as_deref(), Some("ja-JP"));
assert!(ov.geolocation.is_some());
}
#[test]
fn health_usable_semantics() {
let mut h = ProxyHealth::default();
assert!(h.usable()); h.healthy = Some(true);
assert!(h.usable());
h.healthy = Some(false);
assert!(!h.usable()); }
}