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::collections::HashSet;

use tail_fin_common::BrowserSession;

use tail_fin_common::page::{ensure_on_domain, page_fetch_with_body};
use tail_fin_common::TailFinError;

use crate::auth::{build_headers, extract_ct0};
use crate::graphql::{default_features, resolve_query_id};
use crate::parsing::parse_search_response;
use crate::types::TimelineResponse;

/// Search Twitter/X for tweets matching the given query.
///
/// Strategy: Navigate to x.com search page to discover the live queryId
/// for SearchTimeline, then call the GraphQL API directly via page-context fetch.
pub async fn search_tweets(
    session: &BrowserSession,
    query: &str,
    count: usize,
    cursor: Option<&str>,
) -> Result<TimelineResponse, TailFinError> {
    // Ensure we're on x.com for same-origin fetch and queryId discovery
    ensure_on_domain(session, &["x.com", "twitter.com"]).await?;

    // Extract auth
    let ct0 = extract_ct0(session).await?;
    let headers = build_headers(&ct0);

    // Dynamically discover the SearchTimeline queryId
    let query_id = resolve_query_id(session, "SearchTimeline", "lZ0GCEojmtQfiUQa5oJSEw").await?;

    let mut variables = serde_json::json!({
        "rawQuery": query,
        "count": count,
        "querySource": "typed_query",
        "product": "Latest"
    });
    if let Some(c) = cursor {
        variables["cursor"] = serde_json::json!(c);
    }
    let features = default_features();

    let url = format!("/i/api/graphql/{}/SearchTimeline", query_id);
    let field_toggles = serde_json::json!({
        "withArticleRichContentState": true,
        "withArticlePlainText": false,
        "withGrokAnalyze": false,
        "withDisallowedReplyControls": false
    });
    let body = serde_json::json!({
        "variables": variables,
        "features": features,
        "fieldToggles": field_toggles,
    });

    let data = page_fetch_with_body(session, &url, "POST", &headers, Some(&body)).await?;

    let mut resp = parse_search_response(&data);

    // Deduplicate
    let mut seen = HashSet::new();
    resp.tweets.retain(|t| seen.insert(t.id.clone()));

    resp.tweets.truncate(count);
    Ok(resp)
}