tail-fin-common 0.3.0

Shared infrastructure for tail-fin: error types, page_fetch, cookies, CDP helpers
Documentation
use night_fury_core::BrowserSession;
use serde_json::Value;

use crate::error::TailFinError;

/// Navigate to a URL, wait for the page to settle, then evaluate JavaScript.
///
/// This is the standard pattern for browser actions: navigate → wait for network idle →
/// short sleep → evaluate JS. Used by all adapter action functions.
pub async fn navigate_and_eval(
    session: &BrowserSession,
    url: &str,
    wait_secs: u64,
    js: &str,
) -> Result<Value, TailFinError> {
    session.navigate(url).await?;
    let _ = session.wait_for_network_idle(15000, 1000).await;
    tokio::time::sleep(std::time::Duration::from_secs(wait_secs)).await;
    Ok(session.eval(js).await?)
}

/// Scroll the page to trigger lazy-loading of content.
pub async fn scroll_to_load(
    session: &BrowserSession,
    count: usize,
    delay_ms: u64,
) -> Result<(), TailFinError> {
    for _ in 0..count {
        session
            .eval("window.scrollBy(0, window.innerHeight)")
            .await?;
        tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
    }
    Ok(())
}

/// Apply anti-detection patches to mask browser automation signals.
///
/// Patches navigator.webdriver, window.chrome, plugins, and languages.
/// Use this when connecting to an existing Chrome via --connect where
/// night-fury-core's built-in stealth isn't applied automatically.
pub async fn mask_webdriver(session: &BrowserSession) -> Result<(), TailFinError> {
    session
        .eval(
            r#"Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
               if (!window.chrome) { window.chrome = { runtime: {} }; }
               Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
               Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en', 'zh-TW', 'zh'] });"#,
        )
        .await?;
    Ok(())
}

/// Ensure the browser is on one of the specified domains (e.g. "x.com", "grok.com").
///
/// If the current URL doesn't contain any of the domains, navigates to `https://{domains[0]}`
/// and waits for the network to settle.
pub async fn ensure_on_domain(
    session: &BrowserSession,
    domains: &[&str],
) -> Result<(), TailFinError> {
    let current_url = session.get_url().await?;
    if !domains.iter().any(|d| current_url.contains(d)) {
        session.navigate(&format!("https://{}", domains[0])).await?;
        let _ = session.wait_for_network_idle(15000, 1000).await;
    }
    Ok(())
}

/// Execute a same-origin API fetch using `window.location.origin` as the base URL.
///
/// Use this for Cloudflare-protected APIs where the browser has already cleared
/// the challenge. The request uses the browser's origin (which may differ from
/// the initial URL due to redirects) and includes credentials automatically.
pub async fn browser_origin_fetch(
    session: &BrowserSession,
    path: &str,
    headers: Option<&Value>,
) -> Result<Value, TailFinError> {
    let path_escaped = serde_json::to_string(path).unwrap_or_default();
    let headers_json = headers
        .map(|h| serde_json::to_string(h).unwrap_or_default())
        .unwrap_or_else(|| r#"{"Accept":"application/json"}"#.to_string());

    let js = format!(
        r#"(async () => {{
            try {{
                const resp = await fetch(window.location.origin + {path_escaped}, {{
                    credentials: "include",
                    headers: {headers_json}
                }});
                if (!resp.ok) return {{ __error: true, status: resp.status, statusText: resp.statusText }};
                return await resp.json();
            }} catch (e) {{
                return {{ __error: true, status: 0, statusText: e.toString() }};
            }}
        }})()"#
    );

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

    if result
        .get("__error")
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        let status = result.get("status").and_then(|v| v.as_u64()).unwrap_or(0);
        let text = result
            .get("statusText")
            .and_then(|v| v.as_str())
            .unwrap_or("unknown");
        return Err(TailFinError::Api(format!("HTTP {} {}", status, text)));
    }

    Ok(result)
}

/// Execute a fetch request within the browser's page context.
///
/// This runs `fetch()` inside the current page, which means:
/// - Same-origin policy is satisfied
/// - Cookies are automatically included via `credentials: "include"`
/// - The request appears as a normal frontend request
pub async fn page_fetch(
    session: &BrowserSession,
    url: &str,
    method: &str,
    headers: &Value,
) -> Result<Value, TailFinError> {
    page_fetch_with_body(session, url, method, headers, None).await
}

/// Execute a fetch request with an optional JSON body.
pub async fn page_fetch_with_body(
    session: &BrowserSession,
    url: &str,
    method: &str,
    headers: &Value,
    body: Option<&Value>,
) -> Result<Value, TailFinError> {
    let url_escaped = serde_json::to_string(url).unwrap_or_default();
    let method_escaped = serde_json::to_string(method).unwrap_or_default();
    let headers_json = serde_json::to_string(headers).unwrap_or_default();
    let body_part = match body {
        Some(b) => {
            let body_json = serde_json::to_string(b).unwrap_or_default();
            format!(r#", body: JSON.stringify({})"#, body_json)
        }
        None => String::new(),
    };

    let js = format!(
        r#"
        (async () => {{
            const resp = await fetch({url_escaped}, {{
                method: {method_escaped},
                headers: {headers_json},
                credentials: "include"{body_part}
            }});
            if (!resp.ok) {{
                return {{ __error: true, status: resp.status, statusText: resp.statusText }};
            }}
            return await resp.json();
        }})()
        "#
    );

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

    if result
        .get("__error")
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        let status = result.get("status").and_then(|v| v.as_u64()).unwrap_or(0);
        let text = result
            .get("statusText")
            .and_then(|v| v.as_str())
            .unwrap_or("unknown");
        return Err(TailFinError::Api(format!("HTTP {} {}", status, text)));
    }

    Ok(result)
}