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