1use rand::seq::SliceRandom;
2use reqwest::{header::HeaderMap, Client as HttpClient};
3use serde_json::Value;
4use std::collections::HashMap;
5use std::time::Duration;
6
7use crate::{
8 error::{LbcError, Result},
9 models::{Ad, AdType, Category, Location, OwnerType, Pro, Proxy, SearchResult, Sort, User},
10 utils::{build_search_payload_with_args, build_search_payload_with_url},
11};
12
13#[derive(Debug, Clone)]
15pub struct Client {
16 http_client: HttpClient,
17 timeout: Duration,
18 max_retries: u32,
19}
20
21impl Client {
22 pub fn new() -> Self {
24 Self::builder().build().expect("Failed to create client")
25 }
26
27 pub fn builder() -> ClientBuilder {
29 ClientBuilder::new()
30 }
31
32 pub fn with_http_client(http_client: HttpClient) -> Self {
34 Self {
35 http_client,
36 timeout: Duration::from_secs(30),
37 max_retries: 5,
38 }
39 }
40
41 async fn fetch(&self, method: &str, url: &str, payload: Option<Value>) -> Result<Value> {
42 self.fetch_with_retries(method, url, payload, self.max_retries)
43 .await
44 }
45
46 async fn fetch_with_retries(
47 &self,
48 method: &str,
49 url: &str,
50 payload: Option<Value>,
51 retries_left: u32,
52 ) -> Result<Value> {
53 let mut request = match method.to_uppercase().as_str() {
54 "GET" => self.http_client.get(url),
55 "POST" => self.http_client.post(url),
56 "PUT" => self.http_client.put(url),
57 "DELETE" => self.http_client.delete(url),
58 _ => {
59 return Err(LbcError::InvalidValue(format!(
60 "Unsupported HTTP method: {method}"
61 )));
62 }
63 };
64
65 if let Some(ref data) = payload {
66 request = request.json(data);
67 }
68
69 let response = request.timeout(self.timeout).send().await?;
70
71 let status = response.status();
72
73 if status.is_success() {
74 let json_response: Value = response.json().await?;
75 Ok(json_response)
76 } else if status == 403 {
77 if retries_left > 0 {
78 tokio::time::sleep(Duration::from_millis(1000)).await;
80 return Box::pin(self.fetch_with_retries(
81 method,
82 url,
83 payload.clone(),
84 retries_left - 1,
85 ))
86 .await;
87 }
88 Err(LbcError::DatadomeError(
89 "Access blocked by Datadome: your activity was flagged as suspicious. Please avoid sending excessive requests.".to_string()
90 ))
91 } else if status == 404 || status == 410 {
92 Err(LbcError::NotFoundError(
93 "Unable to find ad or user.".to_string(),
94 ))
95 } else {
96 Err(LbcError::RequestError(format!(
97 "Request failed with status code {}",
98 status.as_u16()
99 )))
100 }
101 }
102
103 pub fn search(&self) -> SearchBuilder {
105 SearchBuilder::new(self.clone())
106 }
107
108 pub async fn get_user(&self, user_id: &str) -> Result<User> {
110 let user_url = format!("https://api.leboncoin.fr/api/user-card/v2/{user_id}/infos");
111 let user_data: Value = self.fetch("GET", &user_url, None).await?;
112
113 let mut user: User = serde_json::from_value(user_data)?;
114
115 if user.is_pro() {
117 let pro_url =
118 format!("https://api.leboncoin.fr/api/onlinestores/v2/users/{user_id}?fields=all");
119 match self.fetch("GET", &pro_url, None).await {
120 Ok(pro_data) => {
121 let pro: Pro = serde_json::from_value(pro_data)?;
122 user = user.with_pro_data(Some(pro));
123 }
124 Err(LbcError::NotFoundError(_)) => {
125 }
127 Err(e) => return Err(e),
128 }
129 }
130
131 Ok(user)
132 }
133
134 pub async fn get_ad(&self, ad_id: &str) -> Result<Ad> {
136 let ad_url = format!("https://api.leboncoin.fr/api/adfinder/v1/classified/{ad_id}");
137 let ad_data: Value = self.fetch("GET", &ad_url, None).await?;
138
139 let ad: Ad = serde_json::from_value(ad_data)?;
140 Ok(ad.with_client(self.clone()))
141 }
142}
143
144impl Default for Client {
145 fn default() -> Self {
146 Self::new()
147 }
148}
149
150pub struct ClientBuilder {
152 proxy: Option<Proxy>,
153 timeout: Duration,
154 max_retries: u32,
155 user_agents: Vec<&'static str>,
156}
157
158impl ClientBuilder {
159 fn new() -> Self {
160 Self {
161 proxy: None,
162 timeout: Duration::from_secs(30),
163 max_retries: 5,
164 user_agents: vec![
165 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
166 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
167 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0",
168 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15",
169 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0",
170 ],
171 }
172 }
173
174 pub fn proxy(mut self, proxy: Proxy) -> Self {
175 self.proxy = Some(proxy);
176 self
177 }
178
179 pub fn timeout(mut self, timeout: Duration) -> Self {
180 self.timeout = timeout;
181 self
182 }
183
184 pub fn max_retries(mut self, max_retries: u32) -> Self {
185 self.max_retries = max_retries;
186 self
187 }
188
189 pub fn build(self) -> Result<Client> {
190 let mut headers = HeaderMap::new();
191
192 let user_agent = self
194 .user_agents
195 .choose(&mut rand::thread_rng())
196 .unwrap_or(&self.user_agents[0]);
197 headers.insert("User-Agent", user_agent.parse().unwrap());
198
199 headers.insert("Sec-Fetch-Dest", "empty".parse().unwrap());
201 headers.insert("Sec-Fetch-Mode", "cors".parse().unwrap());
202 headers.insert("Sec-Fetch-Site", "same-site".parse().unwrap());
203
204 let mut client_builder = HttpClient::builder()
205 .default_headers(headers)
206 .timeout(self.timeout)
207 .cookie_store(true);
208
209 if let Some(proxy) = &self.proxy {
210 let proxy_url = proxy.url();
211 client_builder = client_builder.proxy(reqwest::Proxy::all(&proxy_url)?);
212 }
213
214 let http_client = client_builder.build()?;
215
216 std::mem::drop(http_client.get("https://www.leboncoin.fr/").send());
218
219 Ok(Client {
220 http_client,
221 timeout: self.timeout,
222 max_retries: self.max_retries,
223 })
224 }
225}
226
227pub struct SearchBuilder {
229 client: Client,
230 url: Option<String>,
231 text: Option<String>,
232 category: Category,
233 sort: Sort,
234 locations: Option<Vec<Location>>,
235 limit: u32,
236 limit_alu: u32,
237 page: u32,
238 ad_type: AdType,
239 owner_type: Option<OwnerType>,
240 shippable: Option<bool>,
241 search_in_title_only: bool,
242 ranges: HashMap<String, (i64, i64)>,
243 enums: HashMap<String, Vec<String>>,
244}
245
246impl SearchBuilder {
247 fn new(client: Client) -> Self {
248 Self {
249 client,
250 url: None,
251 text: None,
252 category: Category::ToutesCategories,
253 sort: Sort::Relevance,
254 locations: None,
255 limit: 35,
256 limit_alu: 3,
257 page: 1,
258 ad_type: AdType::Offer,
259 owner_type: None,
260 shippable: None,
261 search_in_title_only: false,
262 ranges: HashMap::new(),
263 enums: HashMap::new(),
264 }
265 }
266
267 pub fn url(mut self, url: String) -> Self {
269 self.url = Some(url);
270 self
271 }
272
273 pub fn text(mut self, text: &str) -> Self {
275 self.text = Some(text.to_string());
276 self
277 }
278
279 pub fn category(mut self, category: Category) -> Self {
281 self.category = category;
282 self
283 }
284
285 pub fn sort(mut self, sort: Sort) -> Self {
287 self.sort = sort;
288 self
289 }
290
291 pub fn locations(mut self, locations: Vec<Location>) -> Self {
293 self.locations = Some(locations);
294 self
295 }
296
297 pub fn location(mut self, location: Location) -> Self {
299 match self.locations {
300 Some(ref mut locs) => locs.push(location),
301 None => self.locations = Some(vec![location]),
302 }
303 self
304 }
305
306 pub fn limit(mut self, limit: u32) -> Self {
308 self.limit = limit;
309 self
310 }
311
312 pub fn limit_alu(mut self, limit_alu: u32) -> Self {
314 self.limit_alu = limit_alu;
315 self
316 }
317
318 pub fn page(mut self, page: u32) -> Self {
320 self.page = page;
321 self
322 }
323
324 pub fn ad_type(mut self, ad_type: AdType) -> Self {
326 self.ad_type = ad_type;
327 self
328 }
329
330 pub fn owner_type(mut self, owner_type: OwnerType) -> Self {
332 self.owner_type = Some(owner_type);
333 self
334 }
335
336 pub fn shippable(mut self, shippable: bool) -> Self {
338 self.shippable = Some(shippable);
339 self
340 }
341
342 pub fn search_in_title_only(mut self, title_only: bool) -> Self {
344 self.search_in_title_only = title_only;
345 self
346 }
347
348 pub fn price(mut self, min: i64, max: i64) -> Self {
350 self.ranges.insert("price".to_string(), (min, max));
351 self
352 }
353
354 pub fn square(mut self, min: i64, max: i64) -> Self {
356 self.ranges.insert("square".to_string(), (min, max));
357 self
358 }
359
360 pub fn rooms(mut self, min: i64, max: i64) -> Self {
362 self.ranges.insert("rooms".to_string(), (min, max));
363 self
364 }
365
366 pub fn bedrooms(mut self, min: i64, max: i64) -> Self {
368 self.ranges.insert("bedrooms".to_string(), (min, max));
369 self
370 }
371
372 pub fn range(mut self, key: &str, min: i64, max: i64) -> Self {
374 self.ranges.insert(key.to_string(), (min, max));
375 self
376 }
377
378 pub fn enum_filter(mut self, key: &str, values: Vec<String>) -> Self {
380 self.enums.insert(key.to_string(), values);
381 self
382 }
383
384 pub fn real_estate_type(mut self, types: Vec<&str>) -> Self {
386 self.enums.insert(
387 "real_estate_type".to_string(),
388 types.iter().map(|s| s.to_string()).collect(),
389 );
390 self
391 }
392
393 pub async fn execute(self) -> Result<SearchResult> {
395 let payload = if let Some(url) = &self.url {
396 build_search_payload_with_url(url, self.limit, self.limit_alu, self.page)?
397 } else {
398 let locations = self.locations.as_deref();
399 let ranges = if self.ranges.is_empty() {
400 None
401 } else {
402 Some(self.ranges)
403 };
404 let enums = if self.enums.is_empty() {
405 None
406 } else {
407 Some(self.enums)
408 };
409
410 build_search_payload_with_args(
411 self.text.as_deref(),
412 self.category,
413 self.sort,
414 locations,
415 self.limit,
416 self.limit_alu,
417 self.page,
418 self.ad_type,
419 self.owner_type,
420 self.shippable,
421 self.search_in_title_only,
422 ranges,
423 enums,
424 )
425 };
426
427 let response = self
428 .client
429 .fetch(
430 "POST",
431 "https://api.leboncoin.fr/finder/search",
432 Some(payload),
433 )
434 .await?;
435 let mut result: SearchResult = serde_json::from_value(response)?;
436 result = result.with_client(self.client);
437 Ok(result)
438 }
439}