tail-fin-cli 0.3.0

Multi-site browser automation CLI — Twitter, Reddit, Bloomberg, Coupang, PCC, Instagram, YouTube, Grok, SeekingAlpha, Xiaohongshu, 591, Nansen
use clap::Subcommand;
use tail_fin_common::TailFinError;

use crate::session::{browser_session, print_json, Ctx};

#[derive(Subcommand)]
pub enum NansenAction {
    /// Debug: inspect rendered page content
    #[command(hide = true)]
    Discover {
        #[arg(default_value = "/tokens")]
        page: String,
    },
    /// Search tokens, entities, or addresses
    Search {
        query: String,
        #[arg(long, default_value = "any")]
        result_type: String,
        #[arg(long, default_value_t = 10)]
        limit: usize,
    },
    /// Token information and metrics
    TokenInfo {
        address: String,
        #[arg(long, default_value = "ethereum")]
        chain: String,
    },
    /// Discover and screen tokens
    TokenScreener {
        #[arg(long, default_value = "ethereum,solana")]
        chains: String,
        #[arg(long, default_value = "netflow")]
        sort: String,
        #[arg(long, default_value_t = 1)]
        page: u32,
    },
    /// Token flows by holder segment
    TokenFlows {
        address: String,
        #[arg(long, default_value = "ethereum")]
        chain: String,
        #[arg(long, default_value = "7d")]
        period: String,
    },
    /// Top token holders
    TokenHolders {
        address: String,
        #[arg(long, default_value = "ethereum")]
        chain: String,
        #[arg(long, default_value_t = 1)]
        page: u32,
    },
    /// Token DEX trades
    TokenTrades {
        address: String,
        #[arg(long, default_value = "ethereum")]
        chain: String,
        #[arg(long, default_value_t = 1)]
        page: u32,
    },
    /// Token transfers
    TokenTransfers {
        address: String,
        #[arg(long, default_value = "ethereum")]
        chain: String,
        #[arg(long, default_value_t = 1)]
        page: u32,
    },
    /// Smart money net flows
    SmartMoneyNetflow {
        #[arg(long, default_value = "ethereum,solana")]
        chains: String,
    },
    /// Smart money DEX trades
    SmartMoneyTrades {
        #[arg(long, default_value = "ethereum,solana")]
        chains: String,
        #[arg(long, default_value_t = 1)]
        page: u32,
    },
    /// Smart money token holdings
    SmartMoneyHoldings {
        #[arg(long, default_value = "all")]
        chains: String,
        #[arg(long, default_value_t = 1)]
        page: u32,
    },
    /// Wallet portfolio overview
    Wallet { address: String },
    /// Recent wallet transactions
    WalletTxns {
        address: String,
        #[arg(long, default_value = "evm")]
        chain: String,
        #[arg(long, default_value_t = 1)]
        page: u32,
    },
    /// Wallet counterparty analysis
    WalletCounterparties {
        address: String,
        #[arg(long, default_value_t = 1)]
        page: u32,
    },
    /// Hyperliquid trader leaderboard
    HlLeaderboard {
        #[arg(long, default_value = "totalPnl")]
        sort: String,
        #[arg(long, default_value_t = 1)]
        page: u32,
    },
    /// Chain growth rankings
    ChainGrowth {
        #[arg(long, default_value = "7d")]
        period: String,
    },
}

pub async fn run(action: NansenAction, ctx: &Ctx) -> Result<(), TailFinError> {
    let session = if let Some(ref host) = ctx.connect {
        browser_session(host, ctx.headed).await?
    } else {
        eprintln!("Launching stealth browser for Nansen (Cloudflare bypass)...");
        let s = night_fury_core::BrowserSession::builder()
            .headed(ctx.headed)
            .cloudflare_timeout(std::time::Duration::from_secs(30))
            .launch_stealth("https://app.nansen.ai")
            .await?;
        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
        s
    };

    let client = tail_fin_nansen::NansenClient::new(session);

    if let Some(ref cookies_flag) = ctx.cookies {
        let path = if cookies_flag == "auto" {
            crate::session::default_cookies_path("nansen")
        } else {
            std::path::PathBuf::from(cookies_flag)
        };
        eprintln!("Injecting auth cookies from {}...", path.display());
        client.inject_cookies(&path).await?;
    }

    match action {
        NansenAction::Discover { page } => {
            print_json(&client.discover_api_routes(&page).await?)?;
        }
        NansenAction::Search {
            query,
            result_type,
            limit,
        } => {
            print_json(&client.search(&query, &result_type, limit).await?)?;
        }
        NansenAction::TokenInfo { address, chain } => {
            print_json(&client.token_info(&address, &chain).await?)?;
        }
        NansenAction::TokenScreener { chains, sort, page } => {
            let c: Vec<&str> = chains.split(',').collect();
            print_json(&client.token_screener(&c, &sort, page).await?)?;
        }
        NansenAction::TokenFlows {
            address,
            chain,
            period,
        } => {
            print_json(&client.token_flows(&address, &chain, &period).await?)?;
        }
        NansenAction::TokenHolders {
            address,
            chain,
            page,
        } => {
            print_json(&client.token_holders(&address, &chain, page).await?)?;
        }
        NansenAction::TokenTrades {
            address,
            chain,
            page,
        } => {
            print_json(&client.token_trades(&address, &chain, page).await?)?;
        }
        NansenAction::TokenTransfers {
            address,
            chain,
            page,
        } => {
            print_json(&client.token_transfers(&address, &chain, page).await?)?;
        }
        NansenAction::SmartMoneyNetflow { chains } => {
            let c: Vec<&str> = chains.split(',').collect();
            print_json(&client.smart_money_netflow(&c).await?)?;
        }
        NansenAction::SmartMoneyTrades { chains, page } => {
            let c: Vec<&str> = chains.split(',').collect();
            print_json(&client.smart_money_trades(&c, page).await?)?;
        }
        NansenAction::SmartMoneyHoldings { chains, page } => {
            let c: Vec<&str> = chains.split(',').collect();
            print_json(&client.smart_money_holdings(&c, page).await?)?;
        }
        NansenAction::Wallet { address } => {
            print_json(&client.wallet(&address).await?)?;
        }
        NansenAction::WalletTxns {
            address,
            chain,
            page,
        } => {
            print_json(&client.wallet_txns(&address, &chain, page).await?)?;
        }
        NansenAction::WalletCounterparties { address, page } => {
            print_json(&client.wallet_counterparties(&address, page).await?)?;
        }
        NansenAction::HlLeaderboard { sort, page } => {
            print_json(&client.hl_leaderboard(&sort, page).await?)?;
        }
        NansenAction::ChainGrowth { period } => {
            print_json(&client.chain_growth(&period).await?)?;
        }
    }
    Ok(())
}

pub struct Adapter;

impl crate::adapter::CliAdapter for Adapter {
    fn name(&self) -> &'static str {
        "nansen"
    }
    fn about(&self) -> &'static str {
        "Nansen blockchain analytics (stealth browser + cookies)"
    }

    fn command(&self) -> clap::Command {
        <NansenAction as clap::Subcommand>::augment_subcommands(
            clap::Command::new("nansen")
                .about("Nansen blockchain analytics (stealth browser + cookies)"),
        )
    }

    fn dispatch<'a>(
        &'a self,
        matches: &'a clap::ArgMatches,
        ctx: &'a crate::session::Ctx,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), TailFinError>> + Send + 'a>>
    {
        Box::pin(async move {
            let action = <NansenAction as clap::FromArgMatches>::from_arg_matches(matches)
                .map_err(|e| TailFinError::Api(e.to_string()))?;
            run(action, ctx).await
        })
    }
}