eoka-server 0.4.0

Shared browser runtime and sidecar process for eoka browser automation
Documentation
use eoka_server::eoka::Browser;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fs;
use std::path::Path;
use std::time::Duration;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entity {
    name: String,
    state: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
struct EntityResult {
    company: String,
    state: String,
    status: String,
    registered_agent: Option<String>,
    incorporation_date: Option<String>,
    last_filing: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<String>,
}

struct AntiCaptchaSolver {
    api_key: String,
    client: reqwest::Client,
}

impl AntiCaptchaSolver {
    fn new(api_key: String) -> Self {
        Self {
            api_key,
            client: reqwest::Client::new(),
        }
    }

    async fn solve_hcaptcha(
        &self,
        website_url: &str,
        website_key: &str,
    ) -> Result<String, Box<dyn std::error::Error>> {
        let create_resp = self
            .client
            .post("https://api.anti-captcha.com/createTask")
            .json(&json!({
                "clientKey": self.api_key,
                "task": {
                    "type": "HCaptchaTaskProxyless",
                    "websiteURL": website_url,
                    "websiteKey": website_key,
                }
            }))
            .send()
            .await?;

        let create_data: serde_json::Value = create_resp.json().await?;

        if create_data.get("errorId").map(|v| v.as_u64()) != Some(Some(0)) {
            return Err(format!(
                "Failed to create task: {}",
                create_data.get("errorCode").unwrap_or(&json!("unknown"))
            )
            .into());
        }

        let task_id = create_data["taskId"]
            .as_u64()
            .ok_or("No task ID returned")?;

        for attempt in 0..300 {
            tokio::time::sleep(Duration::from_millis(500)).await;

            let result_resp = self
                .client
                .post("https://api.anti-captcha.com/getTaskResult")
                .json(&json!({
                    "clientKey": self.api_key,
                    "taskId": task_id
                }))
                .send()
                .await?;

            let result_data: serde_json::Value = result_resp.json().await?;

            if result_data.get("errorId").map(|v| v.as_u64()) != Some(Some(0)) {
                return Err(format!(
                    "Failed to get result: {}",
                    result_data.get("errorCode").unwrap_or(&json!("unknown"))
                )
                .into());
            }

            if result_data.get("ready").map(|v| v.as_bool()) == Some(Some(true)) {
                if let Some(solution) = result_data.get("solution") {
                    if let Some(token) = solution.get("gRecaptchaResponse").and_then(|v| v.as_str())
                    {
                        return Ok(token.to_string());
                    }
                    if let Some(token) = solution
                        .get("gRecaptchaResponseWithoutSpaces")
                        .and_then(|v| v.as_str())
                    {
                        return Ok(token.to_string());
                    }
                    if let Some(token) = solution.get("text").and_then(|v| v.as_str()) {
                        return Ok(token.to_string());
                    }
                }
                return Err("No solution in response".into());
            }

            if attempt % 10 == 0 && attempt > 0 {
                println!("  ⏳ Captcha solving... ({}s)", attempt / 2);
            }
        }

        Err("Captcha solving timeout (5 minutes)".into())
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config_path = dirs::home_dir()
        .ok_or("Cannot find home directory")?
        .join(".anti-captcha-config");

    let config_content = fs::read_to_string(&config_path)
        .map_err(|_| format!("Cannot read {}", config_path.display()))?;

    let api_key = config_content
        .lines()
        .find_map(|line| {
            if line.contains("ANTI_CAPTCHA_API_KEY=") {
                line.split('=').nth(1).map(|s| s.trim().to_string())
            } else {
                None
            }
        })
        .ok_or("ANTI_CAPTCHA_API_KEY not found in config")?;

    println!("🔑 API Key: {}...", &api_key[..16.min(api_key.len())]);

    let entities_path = Path::new("shell_companies.json");
    if !entities_path.exists() {
        return Err("shell_companies.json not found. Run: bash extract_entities.sh".into());
    }

    let entities_content = fs::read_to_string(entities_path)?;
    let entities: Vec<Entity> = serde_json::from_str(&entities_content)?;

    println!("📋 Loaded {} entities\n", entities.len());

    let solver = AntiCaptchaSolver::new(api_key);

    println!("🌐 Launching stealth browser...");
    let browser = Browser::launch().await?;
    println!("✓ Browser ready\n");

    let mut results = Vec::new();

    for (i, entity) in entities.iter().enumerate() {
        let state = entity.state.as_deref().unwrap_or("USVI");
        println!("[{}/{}] {}", i + 1, entities.len(), entity.name);

        match search_entity(&browser, &solver, &entity.name, state).await {
            Ok(result) => {
                println!("   ✓ Status: {}", result.status);
                results.push(result);
            }
            Err(e) => {
                println!("   ✗ Error: {}", e);
                results.push(EntityResult {
                    company: entity.name.clone(),
                    state: state.to_string(),
                    status: "ERROR".to_string(),
                    registered_agent: None,
                    incorporation_date: None,
                    last_filing: None,
                    error: Some(e.to_string()),
                });
            }
        }

        tokio::time::sleep(Duration::from_secs(1)).await;
    }

    browser.close().await?;

    let output_json = serde_json::to_string_pretty(&results)?;
    fs::write("opencorporates_results.json", output_json)?;

    let active = results.iter().filter(|r| r.status == "ACTIVE").count();
    let dissolved = results.iter().filter(|r| r.status == "DISSOLVED").count();
    let not_found = results.iter().filter(|r| r.status == "NOT_FOUND").count();
    let errors = results.iter().filter(|r| r.status == "ERROR").count();

    println!("\n✅ Complete! Results saved to: opencorporates_results.json");
    println!("\n📊 Summary:");
    println!("   Active: {}", active);
    println!("   Dissolved: {}", dissolved);
    println!("   Not Found: {}", not_found);
    println!("   Errors: {}", errors);

    Ok(())
}

async fn search_entity(
    browser: &Browser,
    solver: &AntiCaptchaSolver,
    company: &str,
    state: &str,
) -> Result<EntityResult, Box<dyn std::error::Error>> {
    let url = format!(
        "https://opencorporates.com/search?q={}&jurisdiction_code={}",
        company.replace(' ', "+"),
        state
    );

    println!("   🔍 Searching...");

    let page = browser.new_page("https://about:blank").await?;
    page.goto(&url).await?;
    tokio::time::sleep(Duration::from_secs(1)).await;

    let sitekey: Option<String> = page
        .evaluate(
            r#"
            (function() {
                const elem = document.querySelector('[data-sitekey]');
                return elem ? elem.getAttribute('data-sitekey') : null;
            })()
            "#,
        )
        .await
        .ok()
        .flatten();

    if let Some(key) = sitekey {
        println!("   🔒 CAPTCHA detected");
        println!("   🤖 Solving CAPTCHA...");

        match solver.solve_hcaptcha(&url, &key).await {
            Ok(token) => {
                println!("   ✓ CAPTCHA solved");

                let _: serde_json::Value = page
                    .evaluate(&format!(
                        r#"
                    document.querySelector('[name="h-captcha-response"]').value = '{}';
                    document.querySelector('form').submit();
                    "#,
                        token
                    ))
                    .await
                    .unwrap_or(serde_json::Value::Null);

                tokio::time::sleep(Duration::from_secs(2)).await;
            }
            Err(e) => {
                println!("   ⚠ CAPTCHA solve failed: {}", e);
            }
        }
    }

    let result = EntityResult {
        company: company.to_string(),
        state: state.to_string(),
        status: "FOUND".to_string(),
        registered_agent: None,
        incorporation_date: None,
        last_filing: None,
        error: None,
    };

    Ok(result)
}