tail-fin-cli 0.5.1

Multi-site browser automation CLI — attaches to Chrome or auto-launches a stealth browser to drive 15+ sites
use clap::Subcommand;
use tail_fin_common::TailFinError;
use tail_fin_sa::{Period, SeekingAlphaApi, SeekingAlphaClient};

use crate::session::{default_cookies_path, print_json, print_list, resolve_cookies_path, Ctx};

#[derive(Subcommand)]
pub enum SeekingAlphaAction {
    /// Get annual or quarterly income statement
    IncomeStatement {
        /// Ticker symbol (e.g. ONDS, AAPL)
        ticker: String,
        /// Period: annual or quarterly
        #[arg(long, default_value = "annual", value_parser = parse_period)]
        period: Period,
    },
    /// Get annual or quarterly balance sheet
    BalanceSheet {
        /// Ticker symbol
        ticker: String,
        /// Period: annual or quarterly
        #[arg(long, default_value = "annual", value_parser = parse_period)]
        period: Period,
    },
    /// Get annual or quarterly cash flow statement
    CashFlow {
        /// Ticker symbol
        ticker: String,
        /// Period: annual or quarterly
        #[arg(long, default_value = "annual", value_parser = parse_period)]
        period: Period,
    },
    /// Get live quote (price, market cap, PE, EPS, volume)
    Quote {
        /// Ticker symbol
        ticker: String,
    },
    /// Get news articles for a ticker
    News {
        /// Ticker symbol (e.g. AAPL, TSLA)
        ticker: String,
        #[arg(long, default_value_t = 20)]
        count: usize,
        /// Start date (YYYY-MM-DD)
        #[arg(long)]
        since: Option<String>,
        /// End date (YYYY-MM-DD)
        #[arg(long)]
        until: Option<String>,
    },
    /// Get analysis articles for a ticker
    Analysis {
        /// Ticker symbol (e.g. AAPL, TSLA)
        ticker: String,
        #[arg(long, default_value_t = 20)]
        count: usize,
        /// Start date (YYYY-MM-DD)
        #[arg(long)]
        since: Option<String>,
        /// End date (YYYY-MM-DD)
        #[arg(long)]
        until: Option<String>,
    },
    /// Get full article content by ID
    Article {
        /// Article ID (from news/analysis output)
        id: String,
    },
    /// Export SeekingAlpha cookies from Chrome for offline use
    ExportCookies {
        /// Output path (default: ~/.tail-fin/sa-cookies.txt)
        #[arg(long, value_name = "PATH")]
        output: Option<String>,
    },
}

fn parse_period(s: &str) -> Result<Period, String> {
    match s.to_lowercase().as_str() {
        "annual" | "a" => Ok(Period::Annual),
        "quarterly" | "q" => Ok(Period::Quarterly),
        _ => Err(format!("unknown period '{}': use annual or quarterly", s)),
    }
}

fn parse_date_to_timestamp(s: &str) -> Result<i64, String> {
    // Parse YYYY-MM-DD to Unix timestamp (midnight UTC)
    let parts: Vec<&str> = s.split('-').collect();
    if parts.len() != 3 {
        return Err(format!("invalid date '{}': expected YYYY-MM-DD", s));
    }
    let y: i64 = parts[0]
        .parse()
        .map_err(|_| format!("invalid year in '{}'", s))?;
    let m: i64 = parts[1]
        .parse()
        .map_err(|_| format!("invalid month in '{}'", s))?;
    let d: i64 = parts[2]
        .parse()
        .map_err(|_| format!("invalid day in '{}'", s))?;
    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
        return Err(format!("invalid date '{}'", s));
    }
    // Days from civil date to Unix epoch (Howard Hinnant's algorithm)
    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
    let era = (if y >= 0 { y } else { y - 399 }) / 400;
    let yoe = y - era * 400;
    let doy = (153 * m + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era * 146097 + doe - 719468;
    Ok(days * 86400)
}

pub async fn run(action: SeekingAlphaAction, ctx: &Ctx) -> Result<(), TailFinError> {
    // export-cookies always needs --connect
    if let SeekingAlphaAction::ExportCookies { output } = action {
        let chrome_host = ctx.connect.as_deref().unwrap_or("127.0.0.1:9222");
        let out_path = output
            .map(std::path::PathBuf::from)
            .unwrap_or_else(|| default_cookies_path("sa"));
        let count = tail_fin_sa::http::export_cookies(chrome_host, &out_path).await?;
        eprintln!("Saved {} cookies to {}", count, out_path.display());
        return Ok(());
    }

    // All read commands support both --cookies and --connect.
    if let Some(ref cookies_flag) = ctx.cookies {
        let path = resolve_cookies_path(cookies_flag, "sa");
        let client = tail_fin_sa::http::SeekingAlphaHttpClient::from_cookie_file(&path)?;
        return dispatch(action, &client).await;
    }

    let chrome_host = ctx.connect.as_deref().ok_or_else(|| {
        TailFinError::Api(
            "SeekingAlpha commands require --connect or --cookies.\n  Browser mode:  tail-fin --connect 127.0.0.1:9222 sa income-statement ONDS\n  Cookie mode:   tail-fin --cookies sa income-statement ONDS\n  Export first:  tail-fin --connect 127.0.0.1:9222 sa export-cookies".into(),
        )
    })?;

    let session = crate::session::browser_session(chrome_host, ctx.headed).await?;
    let client = SeekingAlphaClient::new(session);
    dispatch(action, &client).await
}

async fn dispatch(
    action: SeekingAlphaAction,
    client: &impl SeekingAlphaApi,
) -> Result<(), TailFinError> {
    match action {
        SeekingAlphaAction::IncomeStatement { ticker, period } => {
            let stmt = client.income_statement(&ticker, period).await?;
            print_json(&stmt)?;
        }
        SeekingAlphaAction::BalanceSheet { ticker, period } => {
            let stmt = client.balance_sheet(&ticker, period).await?;
            print_json(&stmt)?;
        }
        SeekingAlphaAction::CashFlow { ticker, period } => {
            let stmt = client.cash_flow(&ticker, period).await?;
            print_json(&stmt)?;
        }
        SeekingAlphaAction::Quote { ticker } => {
            let quote = client.quote(&ticker).await?;
            print_json(&quote)?;
        }
        SeekingAlphaAction::News {
            ticker,
            count,
            since,
            until,
        } => {
            let since_ts = since
                .map(|s| parse_date_to_timestamp(&s))
                .transpose()
                .map_err(TailFinError::Parse)?;
            let until_ts = until
                .map(|s| parse_date_to_timestamp(&s))
                .transpose()
                .map_err(TailFinError::Parse)?;
            let articles = client.news(&ticker, count, since_ts, until_ts).await?;
            print_list("articles", &articles, articles.len())?;
        }
        SeekingAlphaAction::Analysis {
            ticker,
            count,
            since,
            until,
        } => {
            let since_ts = since
                .map(|s| parse_date_to_timestamp(&s))
                .transpose()
                .map_err(TailFinError::Parse)?;
            let until_ts = until
                .map(|s| parse_date_to_timestamp(&s))
                .transpose()
                .map_err(TailFinError::Parse)?;
            let articles = client.analysis(&ticker, count, since_ts, until_ts).await?;
            print_list("articles", &articles, articles.len())?;
        }
        SeekingAlphaAction::Article { id } => {
            let content = client.article(&id).await?;
            print_json(&content)?;
        }
        SeekingAlphaAction::ExportCookies { .. } => unreachable!(),
    }
    Ok(())
}

pub struct Adapter;

impl crate::adapter::CliAdapter for Adapter {
    fn name(&self) -> &'static str {
        "sa"
    }

    fn about(&self) -> &'static str {
        "SeekingAlpha financial data"
    }

    fn command(&self) -> clap::Command {
        <SeekingAlphaAction as clap::Subcommand>::augment_subcommands(
            clap::Command::new("sa").about("SeekingAlpha financial data"),
        )
    }

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