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 serde_json::Value;
use tail_fin_common::BrowserSession;

use tail_fin_common::TailFinError;

/// Dynamically resolve the queryId for a given GraphQL endpoint.
///
/// Uses a two-tier strategy:
/// 1. First, try fetching from the community-maintained twitter-openapi mapping on GitHub
/// 2. If that fails, scan the page's loaded JS bundles for the queryId
/// 3. Finally, fall back to a hardcoded ID
pub async fn resolve_query_id(
    session: &BrowserSession,
    endpoint: &str,
    fallback: &str,
) -> Result<String, TailFinError> {
    let js = format!(
        r#"
        (async () => {{
            const operationName = "{endpoint}";

            // Strategy 1: Community-maintained queryId mapping (twitter-openapi)
            try {{
                const ghResp = await fetch(
                    'https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json'
                );
                if (ghResp.ok) {{
                    const data = await ghResp.json();
                    const entry = data?.[operationName];
                    if (entry && entry.queryId) return entry.queryId;
                }}
            }} catch {{}}

            // Strategy 2: Scan loaded JS bundles via script tags
            try {{
                const scripts = Array.from(document.querySelectorAll('script[src]')).map(s => s.src);
                for (const scriptUrl of scripts) {{
                    try {{
                        const text = await (await fetch(scriptUrl)).text();
                        const re = new RegExp(
                            'queryId:"([A-Za-z0-9_-]+)"[^{{}}]{{0,200}}operationName:"' + operationName + '"'
                        );
                        const match = text.match(re);
                        if (match) return match[1];
                    }} catch {{}}
                }}
            }} catch {{}}

            return null;
        }})()
        "#
    );

    let result = session.eval(&js).await?;

    if let Some(id) = result.as_str() {
        if !id.is_empty() {
            return Ok(id.to_string());
        }
    }

    Ok(fallback.to_string())
}

/// Build a Twitter GraphQL API URL with encoded variables and features.
pub fn build_graphql_url(
    query_id: &str,
    endpoint: &str,
    variables: &Value,
    features: &Value,
) -> String {
    let vars_str = serde_json::to_string(variables).unwrap_or_default();
    let feats_str = serde_json::to_string(features).unwrap_or_default();
    let vars_encoded = urlencoding::encode(&vars_str);
    let feats_encoded = urlencoding::encode(&feats_str);

    format!(
        "/i/api/graphql/{}/{}?variables={}&features={}",
        query_id, endpoint, vars_encoded, feats_encoded
    )
}

/// Standard Twitter GraphQL feature flags used across most endpoints.
pub fn default_features() -> Value {
    serde_json::json!({
        "rweb_tipjar_consumption_enabled": true,
        "responsive_web_graphql_exclude_directive_enabled": true,
        "verified_phone_label_enabled": false,
        "creator_subscriptions_tweet_preview_api_enabled": true,
        "responsive_web_graphql_timeline_navigation_enabled": true,
        "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
        "communities_web_enable_tweet_community_results_fetch": true,
        "c9s_tweet_anatomy_moderator_badge_enabled": true,
        "articles_preview_enabled": true,
        "responsive_web_edit_tweet_api_enabled": true,
        "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
        "view_counts_everywhere_api_enabled": true,
        "longform_notetweets_consumption_enabled": true,
        "responsive_web_twitter_article_tweet_consumption_enabled": true,
        "tweet_awards_web_tipping_enabled": false,
        "creator_subscriptions_quote_tweet_preview_enabled": false,
        "freedom_of_speech_not_reach_fetch_enabled": true,
        "standardized_nudges_misinfo": true,
        "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
        "rweb_video_timestamps_enabled": true,
        "longform_notetweets_rich_text_read_enabled": true,
        "longform_notetweets_inline_media_enabled": true,
        "responsive_web_enhance_cards_enabled": false
    })
}