crawlkit_extensions/firecrawl/
mod.rs1use std::collections::HashMap;
24
25use async_trait::async_trait;
26use backon::{ExponentialBuilder, Retryable};
27use crawlkit_core::{CrawlError, HttpClient, Response};
28use rand::Rng;
29
30pub struct FirecrawlClient {
34 api_tokens: Vec<String>,
35 max_retries: usize,
36}
37
38impl FirecrawlClient {
39 pub fn builder() -> FirecrawlClientBuilder {
41 FirecrawlClientBuilder::default()
42 }
43}
44
45pub struct FirecrawlClientBuilder {
47 api_tokens: Vec<String>,
48 max_retries: usize,
49}
50
51impl Default for FirecrawlClientBuilder {
52 fn default() -> Self {
53 Self {
54 api_tokens: Vec::new(),
55 max_retries: 3,
56 }
57 }
58}
59
60impl FirecrawlClientBuilder {
61 pub fn with_tokens(mut self, tokens: &[impl Into<String> + Clone]) -> Self {
63 self.api_tokens = tokens.iter().map(|t| t.clone().into()).collect();
64 self
65 }
66
67 pub fn with_tokens_from_env(mut self, env_var: &str) -> Self {
69 if let Ok(val) = std::env::var(env_var) {
70 self.api_tokens = val.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
71 }
72 self
73 }
74
75 pub fn with_max_retries(mut self, max_retries: usize) -> Self {
77 self.max_retries = max_retries;
78 self
79 }
80
81 pub fn build(self) -> anyhow::Result<FirecrawlClient> {
87 if self.api_tokens.is_empty() {
88 anyhow::bail!("Firecrawl 至少需要一个 API Token");
89 }
90 Ok(FirecrawlClient {
91 api_tokens: self.api_tokens,
92 max_retries: self.max_retries,
93 })
94 }
95}
96
97#[async_trait]
98impl HttpClient for FirecrawlClient {
99 async fn get(&self, url: &str, _headers: &HashMap<String, String>) -> crawlkit_core::Result<Response> {
100 let tokens = self.api_tokens.clone();
101 let max_retries = self.max_retries;
102
103 let fetch = || {
104 let tokens = tokens.clone();
105 async move {
106 let token = {
107 let mut rng = rand::rng();
108 let index = rng.random_range(0..tokens.len());
109 tokens[index].clone()
110 };
111
112 let client = firecrawl2::Client::new(&token)
113 .map_err(|e: firecrawl2::error::FirecrawlError| anyhow::anyhow!("Firecrawl 客户端创建失败: {e}"))?;
114
115 let options = firecrawl2::ScrapeOptions {
116 formats: Some(vec![firecrawl2::Format::Html]),
117 only_main_content: Some(true),
118 ..Default::default()
119 };
120
121 let doc = client.scrape(url, options).await
122 .map_err(|e: firecrawl2::error::FirecrawlError| anyhow::anyhow!("Firecrawl 请求失败: {e}"))?;
123
124 let html = doc.html
125 .ok_or_else(|| anyhow::anyhow!("Firecrawl 返回 HTML 内容为空"))?;
126
127 Ok::<(String, String), anyhow::Error>((html, url.to_string()))
128 }
129 };
130
131 let (body, final_url) = fetch
132 .retry(&ExponentialBuilder::default().with_max_times(max_retries))
133 .await
134 .map_err(|e| CrawlError::Http(format!("Firecrawl 请求失败(重试 {max_retries} 次): {e}")))?;
135
136 Ok(Response {
137 url: final_url,
138 status: 200,
139 headers: Default::default(),
140 body,
141 })
142 }
143
144 async fn post(
145 &self,
146 url: &str,
147 headers: &HashMap<String, String>,
148 _body: Vec<u8>,
149 ) -> crawlkit_core::Result<Response> {
150 self.get(url, headers).await
151 }
152
153 fn name(&self) -> &str {
154 "firecrawl"
155 }
156}