10 Commits

Author SHA1 Message Date
etienne-hd
00cf534191 1.0.10 2025-10-11 13:54:26 +02:00
Étienne Hodé
8a74cde3b1 Merge pull request #5 from Soopic/main
Fix KeyError when using shippable=True
2025-10-11 13:48:39 +02:00
Soupic
d24b1cc0e6 Merge pull request #1 from Soopic/shippable-bug
shippable bug
2025-10-11 12:31:50 +02:00
Lucas
6ef01383f0 shippable-bug-fixed 2025-10-11 12:22:12 +02:00
etienne-hd
b9ac610b04 1.0.9 2025-08-20 19:46:52 +02:00
etienne-hd
672204dd95 fix #3: patch ssl verification on leboncoin cookie initialization 2025-08-20 19:44:02 +02:00
etienne-hd
4fa9409f78 1.0.8 2025-08-20 16:56:24 +02:00
etienne-hd
8eb8a96e8f feat: add timeout and max_retries parameters to Client methods; introduce NotFoundError exception 2025-08-20 16:53:37 +02:00
etienne-hd
07d200b653 1.0.7 2025-07-06 19:13:52 +02:00
=
feebd85591 added impersonate browser rotation and ssl verification option 2025-07-06 18:47:03 +02:00
7 changed files with 89 additions and 23 deletions

2
.gitignore vendored
View File

@@ -181,7 +181,7 @@ cython_debug/
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the enitre vscode folder
# .vscode/
.vscode/
# Ruff stuff:
.ruff_cache/

View File

@@ -1,3 +1,22 @@
## 1.0.10
### Fix
* KeyError when using shippable=True (#5)
## 1.0.9
### Fix
* Fixed SSL verification issue during Leboncoin cookie initialization.
## 1.0.8
### Added
* `max_retries` and `timeout` parameters to `Client`.
* `NotFoundError` exception raised when an ad or user is not found.
## 1.0.7
### Added
* Automatic rotation of browser impersonation when `impersonate` argument in `Client` is set to None.
* Ability to choose which browser to impersonate via the `impersonate` argument in `Client`.
* Option to disable SSL verification for requests by setting `request_verify` to `False` in `Client`.
## 1.0.6
### Fixed
* "Unknown location type" error when searching with a URL containing a zipcode.

View File

@@ -1,6 +1,6 @@
[project]
name = "lbc"
version = "1.0.6"
version = "1.0.10"
description = "Unofficial client for Leboncoin API"
readme = "README.md"
license = {text = "MIT"}

View File

@@ -1,23 +1,42 @@
from .session import Session
from .models import Proxy, Search, Category, AdType, OwnerType, Sort, Region, Department, City, User, Ad
from .exceptions import DatadomeError, RequestError
from .exceptions import DatadomeError, RequestError, NotFoundError
from .utils import build_search_payload_with_args, build_search_payload_with_url
from typing import Optional, List, Union
from curl_cffi import BrowserTypeLiteral
class Client(Session):
def __init__(self, proxy: Optional[Proxy] = None):
super().__init__(proxy=proxy)
def __init__(self, proxy: Optional[Proxy] = None, impersonate: BrowserTypeLiteral = None,
request_verify: bool = True, timeout: int = 30, max_retries: int = 5):
"""
Initializes a Leboncoin Client instance with optional proxy, browser impersonation, and SSL verification settings.
def _fetch(self, method: str, url: str, payload: Optional[dict] = None, timeout: int = 30) -> Union[dict, None]:
If no `impersonate` value is provided, a random browser type will be selected among common options.
Args:
proxy (Optional[Proxy], optional): Proxy configuration to use for the client. If provided, it will be applied to all requests. Defaults to None.
impersonate (BrowserTypeLiteral, optional): Browser type to impersonate for requests (e.g., "firefox", "chrome", "edge", "safari", "safari_ios", "chrome_android"). If None, a random browser type will be chosen.
request_verify (bool, optional): Whether to verify SSL certificates when sending requests. Set to False to disable SSL verification (not recommended for production). Defaults to True.
timeout (int, optional): Maximum time in seconds to wait for a request before timing out. Defaults to 30.
max_retries (int, optional): Maximum number of times to retry a request in case of failure (403 error). Defaults to 5.
"""
super().__init__(proxy=proxy, impersonate=impersonate, request_verify=request_verify)
self.request_verify = request_verify
self.timeout = timeout
self.max_retries = max_retries
def _fetch(self, method: str, url: str, payload: Optional[dict] = None, timeout: int = 30, max_retries: int = 5) -> Union[dict, None]:
"""
Internal method to send an HTTP request using the configured session.
Args:
method (staticmethod): HTTP method to use (e.g., `GET`, `POST`).
method (str): HTTP method to use (e.g., "GET", "POST").
url (str): Full URL of the API endpoint.
payload (Optional[dict], optional): JSON payload to send with the request. Used for POST/PUT methods. Defaults to None.
timeout (int, optional): Timeout for the request, in seconds. Defaults to 30.
max_retries (int, optional): Number of times to retry the request in case of failure. Defaults to 5.
Raises:
DatadomeError: Raised when the request is blocked by Datadome protection (HTTP 403).
@@ -30,15 +49,21 @@ class Client(Session):
method=method,
url=url,
json=payload,
timeout=timeout
timeout=timeout,
verify=self.request_verify,
)
if response.ok:
return response.json()
elif response.status_code == 403:
if max_retries > 0:
self.session = self._init_session(proxy=self._proxy, impersonate=self._impersonate, request_verify=self.request_verify) # Re-init session
return self._fetch(method=method, url=url, payload=payload, timeout=timeout, max_retries=max_retries - 1)
if self.proxy:
raise DatadomeError(f"Access blocked by Datadome: your proxy appears to have a poor reputation, try to change it.")
else:
raise DatadomeError(f"Access blocked by Datadome: your activity was flagged as suspicious. Please avoid sending excessive requests.")
elif response.status_code == 404 or response.status_code == 410:
raise NotFoundError(f"Unable to find ad or user.")
else:
raise RequestError(f"Request failed with status code {response.status_code}.")
@@ -94,7 +119,7 @@ class Client(Session):
owner_type=owner_type, shippable=shippable, search_in_title_only=search_in_title_only, **kwargs
)
body = self._fetch(method="POST", url="https://api.leboncoin.fr/finder/search", payload=payload)
body = self._fetch(method="POST", url="https://api.leboncoin.fr/finder/search", payload=payload, timeout=self.timeout, max_retries=self.max_retries)
return Search._build(raw=body, client=self)
def get_user(
@@ -113,13 +138,13 @@ class Client(Session):
Returns:
User: A `User` object containing the parsed user information.
"""
user_data = self._fetch(method="GET", url=f"https://api.leboncoin.fr/api/user-card/v2/{user_id}/infos")
user_data = self._fetch(method="GET", url=f"https://api.leboncoin.fr/api/user-card/v2/{user_id}/infos", timeout=self.timeout, max_retries=self.max_retries)
pro_data = None
if user_data.get("account_type") == "pro":
try:
pro_data = self._fetch(method="GET", url=f"https://api.leboncoin.fr/api/onlinestores/v2/users/{user_id}?fields=all")
except Exception:
pro_data = self._fetch(method="GET", url=f"https://api.leboncoin.fr/api/onlinestores/v2/users/{user_id}?fields=all", timeout=self.timeout, max_retries=self.max_retries)
except NotFoundError:
pass # Some professional users may not have a Leboncoin page.
return User._build(user_data=user_data, pro_data=pro_data)
@@ -127,7 +152,7 @@ class Client(Session):
def get_ad(
self,
ad_id: Union[str, int]
):
) -> Ad:
"""
Retrieve detailed information about a classified ad using its ID.
@@ -141,6 +166,6 @@ class Client(Session):
Returns:
Ad: An `Ad` object containing the parsed ad information.
"""
body = self._fetch(method="GET", url=f"https://api.leboncoin.fr/api/adfinder/v1/classified/{ad_id}")
body = self._fetch(method="GET", url=f"https://api.leboncoin.fr/api/adfinder/v1/classified/{ad_id}", timeout=self.timeout, max_retries=self.max_retries)
return Ad._build(raw=body, client=self)

View File

@@ -12,3 +12,7 @@ class RequestError(LBCError):
class DatadomeError(RequestError):
"""Raised when access is blocked by Datadome anti-bot protection."""
class NotFoundError(LBCError):
"""Raised when a user or ad is not found."""

View File

@@ -1,25 +1,43 @@
from .models import Proxy
from curl_cffi import requests
from curl_cffi import requests, BrowserTypeLiteral
from typing import Optional
import random
class Session:
def __init__(self, proxy: Optional[Proxy] = None):
self._session = self._init_session(proxy=proxy)
def __init__(self, proxy: Optional[Proxy] = None, impersonate: BrowserTypeLiteral = None, request_verify: bool = True):
self._session = self._init_session(proxy=proxy, impersonate=impersonate, request_verify=request_verify)
self._proxy = proxy
self._impersonate = impersonate
def _init_session(self, proxy: Optional[Proxy] = None) -> requests.Session:
def _init_session(self, proxy: Optional[Proxy] = None, impersonate: BrowserTypeLiteral = None, request_verify: bool = True) -> requests.Session:
"""
Initializes an HTTP session with optional proxy and browser impersonation.
Initializes an HTTP session with optional proxy configuration and browser impersonation.
If no `impersonate` value is provided, a random browser type will be selected among common options.
Args:
proxy (Optional[Proxy], optional): Proxy configuration to use for the session. If provided, it will be applied to both HTTP and HTTPS traffic.
proxy (Optional[Proxy], optional): Proxy configuration to use for the session. If provided, it will be applied to both HTTP and HTTPS traffic. Defaults to None.
impersonate (BrowserTypeLiteral, optional): Browser type to impersonate for requests (e.g., "firefox", "chrome", "edge", "safari", "safari_ios", "chrome_android"). If None, a random browser type will be chosen.
request_verify (bool, optional): Whether to verify SSL certificates for HTTPS requests. Defaults to True.
Returns:
requests.Session: A configured session instance ready to send requests.
"""
if impersonate == None: # Pick a random browser client
impersonate: BrowserTypeLiteral = random.choice(
[
"chrome",
"edge",
"safari",
"safari_ios",
"chrome_android",
"firefox"
]
)
session = requests.Session(
impersonate="firefox",
impersonate=impersonate,
)
session.headers.update(
@@ -36,7 +54,7 @@ class Session:
"https": proxy.url
}
session.get("https://www.leboncoin.fr/") # Init cookies
session.get("https://www.leboncoin.fr/", verify=request_verify) # Init cookies
return session

View File

@@ -251,7 +251,7 @@ def build_search_payload_with_args(
payload["filters"]["keywords"]["type"] = "subject"
if shippable:
payload["filters"]["locations"]["shippable"] = True
payload["filters"]["location"]["shippable"] = True
if kwargs:
for key, value in kwargs.items():