Skip to main content

lbc/
client.rs

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/// Main client for interacting with the Leboncoin API
14#[derive(Debug, Clone)]
15pub struct Client {
16    http_client: HttpClient,
17    timeout: Duration,
18    max_retries: u32,
19}
20
21impl Client {
22    /// Create a new client with default settings
23    pub fn new() -> Self {
24        Self::builder().build().expect("Failed to create client")
25    }
26
27    /// Create a client builder for custom configuration
28    pub fn builder() -> ClientBuilder {
29        ClientBuilder::new()
30    }
31
32    /// Create a client with custom HTTP client
33    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                // Retry with a new client session
79                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    /// Create a search builder
104    pub fn search(&self) -> SearchBuilder {
105        SearchBuilder::new(self.clone())
106    }
107
108    /// Get user information by user ID
109    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        // Try to get professional data if user is pro
116        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                    // Some professional users may not have a Leboncoin page
126                }
127                Err(e) => return Err(e),
128            }
129        }
130
131        Ok(user)
132    }
133
134    /// Get detailed ad information by ad ID
135    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
150/// Builder for configuring a Client
151pub 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        // Add random user agent
193        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        // Add common headers
200        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        // Initialize cookies by visiting the main page
217        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
227/// Builder for creating search queries
228pub 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    /// Search using a full Leboncoin URL
268    pub fn url(mut self, url: String) -> Self {
269        self.url = Some(url);
270        self
271    }
272
273    /// Set search text/keywords
274    pub fn text(mut self, text: &str) -> Self {
275        self.text = Some(text.to_string());
276        self
277    }
278
279    /// Set category filter
280    pub fn category(mut self, category: Category) -> Self {
281        self.category = category;
282        self
283    }
284
285    /// Set sorting method
286    pub fn sort(mut self, sort: Sort) -> Self {
287        self.sort = sort;
288        self
289    }
290
291    /// Set location filters
292    pub fn locations(mut self, locations: Vec<Location>) -> Self {
293        self.locations = Some(locations);
294        self
295    }
296
297    /// Add a single location filter
298    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    /// Set maximum number of results per page
307    pub fn limit(mut self, limit: u32) -> Self {
308        self.limit = limit;
309        self
310    }
311
312    /// Set number of ALU suggestions
313    pub fn limit_alu(mut self, limit_alu: u32) -> Self {
314        self.limit_alu = limit_alu;
315        self
316    }
317
318    /// Set page number
319    pub fn page(mut self, page: u32) -> Self {
320        self.page = page;
321        self
322    }
323
324    /// Set ad type (offer or demand)
325    pub fn ad_type(mut self, ad_type: AdType) -> Self {
326        self.ad_type = ad_type;
327        self
328    }
329
330    /// Set owner type filter
331    pub fn owner_type(mut self, owner_type: OwnerType) -> Self {
332        self.owner_type = Some(owner_type);
333        self
334    }
335
336    /// Filter for shippable items only
337    pub fn shippable(mut self, shippable: bool) -> Self {
338        self.shippable = Some(shippable);
339        self
340    }
341
342    /// Search only in titles
343    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    /// Add price range filter
349    pub fn price(mut self, min: i64, max: i64) -> Self {
350        self.ranges.insert("price".to_string(), (min, max));
351        self
352    }
353
354    /// Add square meter range filter
355    pub fn square(mut self, min: i64, max: i64) -> Self {
356        self.ranges.insert("square".to_string(), (min, max));
357        self
358    }
359
360    /// Add rooms range filter
361    pub fn rooms(mut self, min: i64, max: i64) -> Self {
362        self.ranges.insert("rooms".to_string(), (min, max));
363        self
364    }
365
366    /// Add bedrooms range filter  
367    pub fn bedrooms(mut self, min: i64, max: i64) -> Self {
368        self.ranges.insert("bedrooms".to_string(), (min, max));
369        self
370    }
371
372    /// Add custom range filter
373    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    /// Add enum filter (multiple values)
379    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    /// Add real estate type filter
385    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    /// Execute the search
394    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}