tail-fin-cli 0.4.0

Multi-site browser automation CLI — attaches to Chrome or auto-launches a stealth browser to drive 14+ sites
use night_fury_core::BrowserSession;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let session = BrowserSession::builder()
        .connect_to("ws://127.0.0.1:9222")
        .build()
        .await?;

    session.navigate("https://x.com").await?;
    tokio::time::sleep(Duration::from_secs(8)).await;

    // Debug 1: List all script tags
    let r = session
        .eval(
            r#"
    (() => {
        const scripts = Array.from(document.querySelectorAll('script[src]')).map(s => s.src);
        return JSON.stringify(scripts.slice(0, 20));
    })()
    "#,
        )
        .await?;
    println!("Scripts on page: {}", r);

    // Debug 2: Try scanning scripts for SearchTimeline
    let r2 = session
        .eval(
            r#"
    (async () => {
        const scripts = Array.from(document.querySelectorAll('script[src]')).map(s => s.src);
        const results = [];
        for (const url of scripts) {
            try {
                const text = await (await fetch(url)).text();
                const re = /queryId:"([A-Za-z0-9_-]+)"[^{}]{0,200}operationName:"SearchTimeline"/;
                const match = text.match(re);
                if (match) {
                    results.push({ url: url.split('/').pop(), queryId: match[1] });
                }
            } catch(e) {
                results.push({ url: url.split('/').pop(), error: e.message });
            }
        }
        return JSON.stringify({ scriptCount: scripts.length, results });
    })()
    "#,
        )
        .await?;
    println!("Bundle scan: {}", r2);

    // Debug 3: Dump a search result structure
    let dump = session.eval(r#"
    (async () => {
        const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
        const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs=1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
        const variables = {"rawQuery":"rust","count":2,"querySource":"typed_query","product":"Latest"};
        const features = {"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};
        const scripts = Array.from(document.querySelectorAll('script[src]')).map(s => s.src);
        let qid = null;
        for (const url of scripts) {
            try {
                const text = await (await fetch(url)).text();
                const re = /queryId:"([A-Za-z0-9_-]+)"[^{}]{0,200}operationName:"SearchTimeline"/;
                const match = text.match(re);
                if (match) { qid = match[1]; break; }
            } catch {}
        }
        const resp = await fetch('/i/api/graphql/' + qid + '/SearchTimeline', {
            method: 'POST',
            headers: { 'Authorization': 'Bearer ' + bearer, 'X-Csrf-Token': ct0, 'X-Twitter-Auth-Type': 'OAuth2Session', 'Content-Type': 'application/json' },
            credentials: 'include',
            body: JSON.stringify({ variables, features })
        });
        const data = await resp.json();
        const entries = data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions?.[0]?.entries || [];
        const first = entries.find(e => e.entryId?.startsWith('tweet-'));
        if (!first) return 'no tweet entry found';
        const result = first?.content?.itemContent?.tweet_results?.result;
        return JSON.stringify({ keys: Object.keys(result || {}), core: result?.core, typename: result?.__typename }, null, 2);
    })()
    "#).await?;
    println!("Tweet structure: {}", dump);

    // Debug 3 original:
    let ct0_js = r#"document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null"#;
    let ct0_val = session.eval(ct0_js).await?;
    println!("ct0 from document.cookie: {}", ct0_val);

    let cookies = session.get_cookies().await?;
    let ct0_cookie = cookies
        .iter()
        .find(|c| c.get("name").and_then(|v| v.as_str()) == Some("ct0"));
    println!(
        "ct0 from get_cookies: {:?}",
        ct0_cookie.map(|c| c.get("value"))
    );

    // Try the actual query with the discovered queryId
    let qid = r2
        .as_str()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
        .and_then(|v| v["results"][0]["queryId"].as_str().map(|s| s.to_string()));
    if let Some(qid) = qid {
        println!("Using queryId: {}", qid);
        // Build proper variables and features
        let test = session.eval(&format!(r#"
        (async () => {{
            const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
            const variables = {{"rawQuery":"rust","count":5,"querySource":"typed_query","product":"Latest"}};
            const features = {{"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}};
            const bearer = decodeURIComponent('AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA');
            const headers = {{
                'Authorization': 'Bearer ' + bearer,
                'X-Csrf-Token': ct0,
                'X-Twitter-Auth-Type': 'OAuth2Session',
                'X-Twitter-Active-User': 'yes',
                'Content-Type': 'application/json'
            }};

            // Try GET first
            const urlGet = '/i/api/graphql/{}/SearchTimeline?variables=' + encodeURIComponent(JSON.stringify(variables)) + '&features=' + encodeURIComponent(JSON.stringify(features));
            const respGet = await fetch(urlGet, {{ headers, credentials: 'include' }});
            const bodyGet = await respGet.text();

            // Try POST
            const urlPost = '/i/api/graphql/{}/SearchTimeline';
            const respPost = await fetch(urlPost, {{
                method: 'POST',
                headers,
                credentials: 'include',
                body: JSON.stringify({{ variables, features }})
            }});
            const bodyPost = await respPost.text();

            return JSON.stringify({{
                get: {{ status: respGet.status, body: bodyGet.substring(0, 300) }},
                post: {{ status: respPost.status, body: bodyPost.substring(0, 300) }}
            }});
        }})()
        "#, qid, qid)).await?;
        println!("Search API test: {}", test);
    }

    Ok(())
}