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
use std::path::{Path, PathBuf};

use serde_json::{json, Value};
use tail_fin_common::BrowserSession;

use tail_fin_common::cookies::{load_netscape_file, write_netscape_file};
use tail_fin_common::{tail_fin_dir, TailFinError};

/// Twitter web app's public Bearer token (same for all users).
pub const BEARER_TOKEN: &str =
    "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs=1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";

/// Default cookie file path: `~/.tail-fin/twitter-cookies.txt`
pub fn default_cookies_path() -> PathBuf {
    tail_fin_dir().join("twitter-cookies.txt")
}

/// Extract the ct0 CSRF token from the browser's cookies.
pub async fn extract_ct0(session: &BrowserSession) -> Result<String, TailFinError> {
    let cookies = session.get_cookies().await?;
    for cookie in &cookies {
        let name = cookie.get("name").and_then(|v| v.as_str()).unwrap_or("");
        if name == "ct0" {
            if let Some(value) = cookie.get("value").and_then(|v| v.as_str()) {
                if !value.is_empty() {
                    return Ok(value.to_string());
                }
            }
        }
    }
    Err(TailFinError::AuthRequired)
}

/// Build the standard headers required for Twitter GraphQL requests.
pub fn build_headers(ct0: &str) -> Value {
    json!({
        "Authorization": format!("Bearer {}", BEARER_TOKEN),
        "X-Csrf-Token": ct0,
        "X-Twitter-Auth-Type": "OAuth2Session",
        "X-Twitter-Active-User": "yes",
        "Content-Type": "application/json"
    })
}

/// Export cookies from Chrome via CDP `Network.getAllCookies` — no navigation needed.
///
/// Connects to Chrome at `chrome_host`, gets all cookies, filters for twitter/x.com,
/// and writes a Netscape cookie file at `path`.
#[cfg(feature = "http")]
pub async fn export_cookies(chrome_host: &str, path: &Path) -> Result<usize, TailFinError> {
    use tail_fin_common::cdp::find_tab_ws_url;

    let ws_url = find_tab_ws_url(chrome_host, Some("x.com")).await?;
    let mut tab = tail_fin_common::cdp::CdpTab::connect(&ws_url).await?;
    let all_cookies = tab.get_all_cookies().await?;

    let twitter_cookies: Vec<&Value> = all_cookies
        .iter()
        .filter(|c| {
            let domain = c.get("domain").and_then(|v| v.as_str()).unwrap_or("");
            domain.contains("twitter.com") || domain.contains("x.com")
        })
        .collect();

    if twitter_cookies.is_empty() {
        return Err(TailFinError::Api(
            "No Twitter/X cookies found. Please log in to x.com in Chrome first.".into(),
        ));
    }

    let owned: Vec<Value> = twitter_cookies.into_iter().cloned().collect();
    write_netscape_file(path, &owned)?;
    Ok(owned.len())
}

/// Load cookies from a Netscape cookie file. Returns `(name, value)` pairs.
pub fn load_cookies_from_file(path: &Path) -> Result<Vec<(String, String)>, TailFinError> {
    load_netscape_file(path)
}

/// Extract ct0 and auth_token from a list of cookie `(name, value)` pairs.
pub fn extract_auth_from_cookies(
    cookies: &[(String, String)],
) -> Result<(String, String), TailFinError> {
    let mut ct0 = None;
    let mut auth_token = None;
    for (name, value) in cookies {
        match name.as_str() {
            "ct0" => ct0 = Some(value.clone()),
            "auth_token" => auth_token = Some(value.clone()),
            _ => {}
        }
    }
    match (ct0, auth_token) {
        (Some(c), Some(a)) => Ok((c, a)),
        (None, _) => Err(TailFinError::Api(
            "ct0 cookie not found. Run `tail-fin --connect twitter export-cookies` to refresh."
                .into(),
        )),
        (_, None) => Err(TailFinError::Api(
            "auth_token not found. Run `tail-fin --connect twitter export-cookies` to refresh."
                .into(),
        )),
    }
}