use std::collections::HashMap;
use std::time::Duration;
use async_trait::async_trait;
use backon::{ExponentialBuilder, Retryable};
use crawlkit_core::{CrawlError, HttpClient, Response};
pub struct JinaClient {
api_token: Option<String>,
client: reqwest::Client,
max_retries: usize,
}
impl JinaClient {
pub fn builder() -> JinaClientBuilder {
JinaClientBuilder::default()
}
fn from_builder(builder: JinaClientBuilder) -> Self {
let client = reqwest::Client::builder()
.timeout(builder.timeout)
.build()
.expect("failed to build reqwest client");
Self {
api_token: builder.api_token,
client,
max_retries: builder.max_retries,
}
}
}
impl Default for JinaClient {
fn default() -> Self {
Self::builder().build()
}
}
pub struct JinaClientBuilder {
api_token: Option<String>,
timeout: Duration,
max_retries: usize,
}
impl Default for JinaClientBuilder {
fn default() -> Self {
Self {
api_token: None,
timeout: Duration::from_secs(60),
max_retries: 3,
}
}
}
impl JinaClientBuilder {
pub fn with_token(mut self, token: impl Into<String>) -> Self {
self.api_token = Some(token.into());
self
}
pub fn with_token_from_env(mut self, env_var: &str) -> Self {
self.api_token = std::env::var(env_var).ok().filter(|s| !s.is_empty());
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_max_retries(mut self, max_retries: usize) -> Self {
self.max_retries = max_retries;
self
}
pub fn build(self) -> JinaClient {
JinaClient::from_builder(self)
}
}
#[async_trait]
impl HttpClient for JinaClient {
async fn get(&self, url: &str, _headers: &HashMap<String, String>) -> crawlkit_core::Result<Response> {
let client = self.client.clone();
let reader_url = format!("https://r.jina.ai/{url}");
let token = self.api_token.clone();
let max_retries = self.max_retries;
let fetch = || {
let client = client.clone();
let reader_url = reader_url.clone();
let token = token.clone();
async move {
let mut req = client
.get(&reader_url)
.header("Accept", "text/markdown");
if let Some(ref t) = token {
req = req.header("Authorization", format!("Bearer {t}"));
}
let resp = req.send().await.map_err(|e| {
anyhow::anyhow!("Jina 请求失败: {e}")
})?;
let status = resp.status().as_u16();
let final_url = resp.url().to_string();
let body = resp.text().await.map_err(|e| {
anyhow::anyhow!("Jina 读取响应失败: {e}")
})?;
if status >= 400 {
return Err(anyhow::anyhow!("Jina 返回错误状态 {status}: {body}"));
}
Ok((final_url, status, body))
}
};
let (final_url, status, body) = fetch
.retry(&ExponentialBuilder::default().with_max_times(max_retries))
.await
.map_err(|e| CrawlError::Http(format!("Jina 请求失败(重试 {max_retries} 次): {e}")))?;
Ok(Response {
url: final_url,
status,
headers: Default::default(),
body,
})
}
async fn post(
&self,
url: &str,
headers: &HashMap<String, String>,
_body: Vec<u8>,
) -> crawlkit_core::Result<Response> {
self.get(url, headers).await
}
fn name(&self) -> &str {
"jina"
}
}