use std::time::Duration;
use chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::cdp::browser_protocol::target::CreateTargetParams;
use chromiumoxide::element::Element;
use chromiumoxide::page::Page;
use futures::StreamExt;
use crate::error::{FinanceError, Result};
pub(super) const SENATE_BASE: &str = "https://efdsearch.senate.gov";
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
const ELEMENT_POLL_INTERVAL: Duration = Duration::from_millis(200);
const ELEMENT_POLL_ATTEMPTS: usize = 75;
const BLOCK_CHECK_ATTEMPTS: usize = 5;
const AKAMAI_BLOCK_MARKERS: [&str; 2] = ["edgesuite.net", "Access Denied"];
async fn akamai_block_reason(page: &Page) -> Option<&'static str> {
let content = page.content().await.ok()?;
AKAMAI_BLOCK_MARKERS
.iter()
.any(|marker| content.contains(marker))
.then_some("blocked by Akamai (IP reputation or rate limit)")
}
fn browser_error(e: impl std::fmt::Display) -> FinanceError {
FinanceError::ApiError(format!("Senate eFD: {e}"))
}
pub(super) struct SenateTradesClient {
browser: Browser,
handler: tokio::task::JoinHandle<()>,
}
impl SenateTradesClient {
pub(super) async fn launch() -> Result<Self> {
let config = BrowserConfig::builder()
.no_sandbox()
.arg("--disable-blink-features=AutomationControlled")
.arg("--headless=new")
.arg(format!("--user-agent={USER_AGENT}"))
.build()
.map_err(browser_error)?;
let (browser, mut handler) = Browser::launch(config).await.map_err(browser_error)?;
let handler = tokio::spawn(async move {
while let Some(event) = handler.next().await {
if event.is_err() {
break;
}
}
});
Ok(Self { browser, handler })
}
pub(super) async fn new_page(&self, url: &str) -> Result<Page> {
self.browser
.new_page(CreateTargetParams::new(url))
.await
.map_err(browser_error)
}
pub(super) async fn close(mut self) {
self.browser.close().await.ok();
self.browser.wait().await.ok();
let _ = (&mut self.handler).await;
}
}
pub(super) async fn find_element_if_present(page: &Page, selector: &str) -> Option<Element> {
for _ in 0..BLOCK_CHECK_ATTEMPTS {
if let Ok(element) = page.find_element(selector).await {
return Some(element);
}
tokio::time::sleep(ELEMENT_POLL_INTERVAL).await;
}
None
}
pub(super) async fn wait_for_element(page: &Page, selector: &str) -> Result<Element> {
for attempt in 0..ELEMENT_POLL_ATTEMPTS {
if let Ok(element) = page.find_element(selector).await {
return Ok(element);
}
if attempt < BLOCK_CHECK_ATTEMPTS
&& let Some(reason) = akamai_block_reason(page).await
{
return Err(browser_error(reason));
}
tokio::time::sleep(ELEMENT_POLL_INTERVAL).await;
}
Err(browser_error(format!("timed out waiting for {selector}")))
}
pub(super) async fn wait_for_elements(page: &Page, selector: &str) -> Result<Vec<Element>> {
for attempt in 0..ELEMENT_POLL_ATTEMPTS {
if let Ok(elements) = page.find_elements(selector).await
&& !elements.is_empty()
{
return Ok(elements);
}
if attempt < BLOCK_CHECK_ATTEMPTS
&& let Some(reason) = akamai_block_reason(page).await
{
return Err(browser_error(reason));
}
tokio::time::sleep(ELEMENT_POLL_INTERVAL).await;
}
Err(browser_error(format!("timed out waiting for {selector}")))
}