tail-fin-twitter 0.5.1

Twitter/X adapter for tail-fin: timeline, search, profile, bookmarks, likes, thread, post, like, follow, block, bookmark, reply, trending, lists, article, download, notifications
Documentation
//! `Site` implementation for Twitter/X.
//!
//! Twitter's cookie refresh requires more than a plain navigate — the
//! GraphQL endpoints only emit `Set-Cookie` after the timeline renders
//! real tweets. We scroll to trigger that, then wait for network idle.

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 {
        // Twitter rate-limits aggressive refreshes. 90s is empirically safe.
        Duration::from_secs(90)
    }

    /// Twitter-specific refresh: navigate → wait for initial render →
    /// scroll to force GraphQL emission → wait for network idle → read
    /// cookies. Default impl's "just navigate + get cookies" leaves
    /// auth_token stale if the timeline hasn't actually rendered tweets.
    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}"),
            })?;

        // Fixed sleep for SPA hydration. Twitter's streaming polls +
        // notifications never let wait_for_network_idle return in any
        // practical timeout — 3s is enough for auth GraphQL to land.
        session.wait(Duration::from_secs(3)).await;

        // Scroll to trigger more GraphQL calls that touch auth cookies.
        let _ = session.scroll(2_000).await; // best-effort

        // Brief settle-time for scroll-triggered requests.
        session.wait(Duration::from_secs(2)).await;

        // Collect cookies from both twitter.com and x.com, dedup by tuple.
        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")
        });

        // Sanity check: must have auth_token.
        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)
    }

    /// Validate by pinging an authenticated endpoint.
    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, // fetch threw
            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,
        }
    }
}