use std::time::Duration;
use async_trait::async_trait;
use serde_json::Value;
use tail_fin_core::{
AuthFailureKind, BrowserSession, FailureIndicators, SessionStatus, Site, SiteError,
};
pub struct TwitterSite;
#[async_trait]
impl Site for TwitterSite {
fn id(&self) -> &'static str {
"twitter"
}
fn display_name(&self) -> &'static str {
"Twitter/X"
}
fn cookie_domain_patterns(&self) -> &'static [&'static str] {
&["*.twitter.com", "*.x.com"]
}
fn refresh_url(&self) -> &'static str {
"https://x.com/home"
}
fn refresh_interval_min(&self) -> Duration {
Duration::from_secs(90)
}
async fn refresh(&self, session: &BrowserSession) -> Result<Vec<Value>, SiteError> {
session
.navigate(self.refresh_url())
.await
.map_err(|e| SiteError::RefreshFailed {
site: self.id(),
reason: format!("navigate: {e}"),
})?;
session.wait(Duration::from_secs(3)).await;
let _ = session.scroll(2_000).await;
session.wait(Duration::from_secs(2)).await;
let mut cookies = Vec::new();
for pattern in self.cookie_domain_patterns() {
let batch = session.get_cookies_for_domain(pattern).await.map_err(|e| {
SiteError::RefreshFailed {
site: self.id(),
reason: format!("get_cookies_for_domain({pattern}): {e}"),
}
})?;
cookies.extend(batch);
}
cookies.sort_by(|a, b| {
let key_a = (
a.get("name").and_then(|v| v.as_str()),
a.get("domain").and_then(|v| v.as_str()),
a.get("path").and_then(|v| v.as_str()),
);
let key_b = (
b.get("name").and_then(|v| v.as_str()),
b.get("domain").and_then(|v| v.as_str()),
b.get("path").and_then(|v| v.as_str()),
);
key_a.cmp(&key_b)
});
cookies.dedup_by(|a, b| {
a.get("name") == b.get("name")
&& a.get("domain") == b.get("domain")
&& a.get("path") == b.get("path")
});
let has_auth = cookies
.iter()
.any(|c| c.get("name").and_then(|v| v.as_str()) == Some("auth_token"));
if !has_auth {
return Err(SiteError::RefreshFailed {
site: self.id(),
reason: "auth_token missing after refresh — likely logged out".into(),
});
}
Ok(cookies)
}
async fn validate(&self, session: &BrowserSession) -> Result<SessionStatus, SiteError> {
let status = session
.http_ping("https://x.com/i/api/2/notifications/all.json")
.await
.map_err(|e| SiteError::ValidationFailed {
site: self.id(),
reason: format!("ping failed: {e}"),
})?;
Ok(match status {
200 => SessionStatus::Valid,
401 | 403 => SessionStatus::Expired,
429 => SessionStatus::Blocked {
reason: "rate limited".into(),
retry_after: Some(Duration::from_secs(300)),
},
0 => SessionStatus::Unknown, other => SessionStatus::Degrading {
estimated_expiry: None,
hint: format!("unexpected HTTP {other}"),
},
})
}
fn detect_auth_failure(&self, indicators: &FailureIndicators) -> Option<AuthFailureKind> {
match indicators.status {
Some(401) | Some(403) => Some(AuthFailureKind::CookieExpired),
Some(429) => Some(AuthFailureKind::RateLimited {
retry_after: Duration::from_secs(300),
}),
_ if indicators
.body_preview
.contains("This account has been suspended") =>
{
Some(AuthFailureKind::AccountSuspended)
}
_ => None,
}
}
}