diff --git a/pyproject.toml b/pyproject.toml index ddab99e..e189428 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lbc" -version = "1.0.10" +version = "1.1.0" description = "Unofficial client for Leboncoin API" readme = "README.md" license = {text = "MIT"} diff --git a/src/lbc/__init__.py b/src/lbc/__init__.py index 44ffa53..e73d913 100644 --- a/src/lbc/__init__.py +++ b/src/lbc/__init__.py @@ -1,2 +1,2 @@ from .client import Client -from .models import * \ No newline at end of file +from .model import * \ No newline at end of file diff --git a/src/lbc/client.py b/src/lbc/client.py index 0a36334..f71cd81 100644 --- a/src/lbc/client.py +++ b/src/lbc/client.py @@ -1,14 +1,23 @@ -from .session import Session -from .models import Proxy, Search, Category, AdType, OwnerType, Sort, Region, Department, City, User, Ad -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 typing import Optional, Union from curl_cffi import BrowserTypeLiteral -class Client(Session): +from .mixin import ( + SessionMixin, + SearchMixin, + UserMixin, + AdMixin +) +from .model import Proxy +from .exceptions import DatadomeError, RequestError, NotFoundError + +class Client( + SessionMixin, + SearchMixin, + UserMixin, + AdMixin +): def __init__(self, proxy: Optional[Proxy] = None, impersonate: BrowserTypeLiteral = None, - request_verify: bool = True, timeout: int = 30, max_retries: int = 5): + request_verify: bool = True, timeout: float = 30.0, max_retries: int = 5): """ Initializes a Leboncoin Client instance with optional proxy, browser impersonation, and SSL verification settings. @@ -65,107 +74,4 @@ class Client(Session): 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}.") - - def search( - self, - url: Optional[str] = None, - text: Optional[str] = None, - category: Category = Category.TOUTES_CATEGORIES, - sort: Sort = Sort.RELEVANCE, - locations: Optional[Union[List[Union[Region, Department, City]], Union[Region, Department, City]]] = None, - limit: int = 35, - limit_alu: int = 3, - page: int = 1, - ad_type: AdType = AdType.OFFER, - owner_type: Optional[OwnerType] = None, - shippable: Optional[bool] = None, - search_in_title_only: bool = False, - **kwargs - ) -> Search: - """ - Perform a classified ads search on Leboncoin with the specified criteria. - - You can either: - - Provide a full `url` from a Leboncoin search to replicate the search directly. - - Or use the individual parameters (`text`, `category`, `locations`, etc.) to construct a custom search. - - Args: - url (Optional[str], optional): A full Leboncoin search URL. If provided, all other parameters will be ignored and the search will replicate the results from the URL. - text (Optional[str], optional): Search keywords. If None, returns all matching ads without filtering by keyword. Defaults to None. - category (Category, optional): Category to search in. Defaults to Category.TOUTES_CATEGORIES. - sort (Sort, optional): Sorting method for results (e.g., relevance, date, price). Defaults to Sort.RELEVANCE. - locations (Optional[Union[List[Union[Region, Department, City]], Union[Region, Department, City]]], optional): One or multiple locations (region, department, or city) to filter results. Defaults to None. - limit (int, optional): Maximum number of results to return. Defaults to 35. - limit_alu (int, optional): Number of ALU (Annonces Lu / similar ads) suggestions to include. Defaults to 3. - page (int, optional): Page number to retrieve for paginated results. Defaults to 1. - ad_type (AdType, optional): Type of ad (offer or request). Defaults to AdType.OFFER. - owner_type (Optional[OwnerType], optional): Filter by seller type (individual, professional, or all). Defaults to None. - shippable (Optional[bool], optional): If True, only includes ads that offer shipping. Defaults to None. - search_in_title_only (bool, optional): If True, search will only be performed on ad titles. Defaults to False. - **kwargs: Additional advanced filters such as price range (`price=(min, max)`), surface area (`square=(min, max)`), property type, and more. - - Returns: - Search: A `Search` object containing the parsed search results. - """ - if url: - payload = build_search_payload_with_url( - url=url, limit=limit, page=page - ) - else: - payload = build_search_payload_with_args( - text=text, category=category, sort=sort, locations=locations, - limit=limit, limit_alu=limit_alu, page=page, ad_type=ad_type, - 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, timeout=self.timeout, max_retries=self.max_retries) - return Search._build(raw=body, client=self) - - def get_user( - self, - user_id: str - ) -> User: - """ - Retrieve information about a user based on their user ID. - - This method fetches detailed user data such as their profile, professional status, - and other relevant metadata available through the public user API. - - Args: - user_id (str): The unique identifier of the user on Leboncoin. Usually found in the url (e.g 57f99bb6-0446-4b82-b05d-a44ea7bcd2cc). - - 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", 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", 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) - - def get_ad( - self, - ad_id: Union[str, int] - ) -> Ad: - """ - Retrieve detailed information about a classified ad using its ID. - - This method fetches the full content of an ad, including its description, - pricing, location, and other relevant metadata made - available through the public Leboncoin ad API. - - Args: - ad_id (Union[str, int]): The unique identifier of the ad on Leboncoin. Can be found in the ad URL. - - 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}", timeout=self.timeout, max_retries=self.max_retries) - - return Ad._build(raw=body, client=self) \ No newline at end of file + raise RequestError(f"Request failed with status code {response.status_code}.") \ No newline at end of file diff --git a/src/lbc/exceptions.py b/src/lbc/exceptions.py index 0840c08..6f8803d 100644 --- a/src/lbc/exceptions.py +++ b/src/lbc/exceptions.py @@ -1,18 +1,14 @@ class LBCError(Exception): """Base exception for all errors raised by the LBC client.""" - class InvalidValue(LBCError): """Raised when a provided value is invalid or improperly formatted.""" - class RequestError(LBCError): """Raised when an HTTP request fails with a non-success status code.""" - 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.""" diff --git a/src/lbc/mixin/__init__.py b/src/lbc/mixin/__init__.py new file mode 100644 index 0000000..d21397e --- /dev/null +++ b/src/lbc/mixin/__init__.py @@ -0,0 +1,4 @@ +from .session import SessionMixin +from .search import SearchMixin +from .user import UserMixin +from .ad import AdMixin \ No newline at end of file diff --git a/src/lbc/mixin/ad.py b/src/lbc/mixin/ad.py new file mode 100644 index 0000000..0d2b550 --- /dev/null +++ b/src/lbc/mixin/ad.py @@ -0,0 +1,22 @@ +from typing import Union + +from ..model import Ad + +class AdMixin: + def get_ad(self, ad_id: Union[str, int]) -> Ad: + """ + Retrieve detailed information about a classified ad using its ID. + + This method fetches the full content of an ad, including its description, + pricing, location, and other relevant metadata made + available through the public Leboncoin ad API. + + Args: + ad_id (Union[str, int]): The unique identifier of the ad on Leboncoin. Can be found in the ad URL. + + 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}", timeout=self.timeout, max_retries=self.max_retries) + + return Ad._build(raw=body, client=self) \ No newline at end of file diff --git a/src/lbc/mixin/search.py b/src/lbc/mixin/search.py new file mode 100644 index 0000000..269ef9a --- /dev/null +++ b/src/lbc/mixin/search.py @@ -0,0 +1,60 @@ +from typing import Optional, Union, List + +from ..model import Category, Sort, Region, Department, City, AdType, OwnerType, Search +from ..utils import build_search_payload_with_args, build_search_payload_with_url + +class SearchMixin: + def search( + self, + url: Optional[str] = None, + text: Optional[str] = None, + category: Category = Category.TOUTES_CATEGORIES, + sort: Sort = Sort.RELEVANCE, + locations: Optional[Union[List[Union[Region, Department, City]], Union[Region, Department, City]]] = None, + limit: int = 35, + limit_alu: int = 3, + page: int = 1, + ad_type: AdType = AdType.OFFER, + owner_type: Optional[OwnerType] = None, + shippable: Optional[bool] = None, + search_in_title_only: bool = False, + **kwargs + ) -> Search: + """ + Perform a classified ads search on Leboncoin with the specified criteria. + + You can either: + - Provide a full `url` from a Leboncoin search to replicate the search directly. + - Or use the individual parameters (`text`, `category`, `locations`, etc.) to construct a custom search. + + Args: + url (Optional[str], optional): A full Leboncoin search URL. If provided, all other parameters will be ignored and the search will replicate the results from the URL. + text (Optional[str], optional): Search keywords. If None, returns all matching ads without filtering by keyword. Defaults to None. + category (Category, optional): Category to search in. Defaults to Category.TOUTES_CATEGORIES. + sort (Sort, optional): Sorting method for results (e.g., relevance, date, price). Defaults to Sort.RELEVANCE. + locations (Optional[Union[List[Union[Region, Department, City]], Union[Region, Department, City]]], optional): One or multiple locations (region, department, or city) to filter results. Defaults to None. + limit (int, optional): Maximum number of results to return. Defaults to 35. + limit_alu (int, optional): Number of ALU (Annonces Lu / similar ads) suggestions to include. Defaults to 3. + page (int, optional): Page number to retrieve for paginated results. Defaults to 1. + ad_type (AdType, optional): Type of ad (offer or request). Defaults to AdType.OFFER. + owner_type (Optional[OwnerType], optional): Filter by seller type (individual, professional, or all). Defaults to None. + shippable (Optional[bool], optional): If True, only includes ads that offer shipping. Defaults to None. + search_in_title_only (bool, optional): If True, search will only be performed on ad titles. Defaults to False. + **kwargs: Additional advanced filters such as price range (`price=(min, max)`), surface area (`square=(min, max)`), property type, and more. + + Returns: + Search: A `Search` object containing the parsed search results. + """ + if url: + payload = build_search_payload_with_url( + url=url, limit=limit, page=page + ) + else: + payload = build_search_payload_with_args( + text=text, category=category, sort=sort, locations=locations, + limit=limit, limit_alu=limit_alu, page=page, ad_type=ad_type, + 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, timeout=self.timeout, max_retries=self.max_retries) + return Search._build(raw=body, client=self) \ No newline at end of file diff --git a/src/lbc/session.py b/src/lbc/mixin/session.py similarity index 95% rename from src/lbc/session.py rename to src/lbc/mixin/session.py index 5107115..546f7c9 100644 --- a/src/lbc/session.py +++ b/src/lbc/mixin/session.py @@ -1,14 +1,15 @@ -from .models import Proxy +from ..model import Proxy from curl_cffi import requests, BrowserTypeLiteral from typing import Optional import random -class Session: - def __init__(self, proxy: Optional[Proxy] = None, impersonate: BrowserTypeLiteral = None, request_verify: bool = True): +class SessionMixin: + def __init__(self, proxy: Optional[Proxy] = None, impersonate: BrowserTypeLiteral = None, request_verify: bool = True, **kwargs): self._session = self._init_session(proxy=proxy, impersonate=impersonate, request_verify=request_verify) self._proxy = proxy self._impersonate = impersonate + super().__init__(**kwargs) def _init_session(self, proxy: Optional[Proxy] = None, impersonate: BrowserTypeLiteral = None, request_verify: bool = True) -> requests.Session: """ @@ -47,7 +48,6 @@ class Session: 'Sec-Fetch-Site': 'same-site', } ) - if proxy: session.proxies = { "http": proxy.url, @@ -55,7 +55,6 @@ class Session: } session.get("https://www.leboncoin.fr/", verify=request_verify) # Init cookies - return session @property diff --git a/src/lbc/mixin/user.py b/src/lbc/mixin/user.py new file mode 100644 index 0000000..d0749a7 --- /dev/null +++ b/src/lbc/mixin/user.py @@ -0,0 +1,27 @@ +from ..model import User +from ..exceptions import NotFoundError + +class UserMixin: + def get_user(self, user_id: str) -> User: + """ + Retrieve information about a user based on their user ID. + + This method fetches detailed user data such as their profile, professional status, + and other relevant metadata available through the public user API. + + Args: + user_id (str): The unique identifier of the user on Leboncoin. Usually found in the url (e.g 57f99bb6-0446-4b82-b05d-a44ea7bcd2cc). + + 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", 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", 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) \ No newline at end of file diff --git a/src/lbc/models/__init__.py b/src/lbc/model/__init__.py similarity index 100% rename from src/lbc/models/__init__.py rename to src/lbc/model/__init__.py diff --git a/src/lbc/models/ad.py b/src/lbc/model/ad.py similarity index 100% rename from src/lbc/models/ad.py rename to src/lbc/model/ad.py diff --git a/src/lbc/models/city.py b/src/lbc/model/city.py similarity index 100% rename from src/lbc/models/city.py rename to src/lbc/model/city.py diff --git a/src/lbc/models/enums.py b/src/lbc/model/enums.py similarity index 100% rename from src/lbc/models/enums.py rename to src/lbc/model/enums.py diff --git a/src/lbc/models/proxy.py b/src/lbc/model/proxy.py similarity index 100% rename from src/lbc/models/proxy.py rename to src/lbc/model/proxy.py diff --git a/src/lbc/models/search.py b/src/lbc/model/search.py similarity index 100% rename from src/lbc/models/search.py rename to src/lbc/model/search.py diff --git a/src/lbc/models/user.py b/src/lbc/model/user.py similarity index 100% rename from src/lbc/models/user.py rename to src/lbc/model/user.py diff --git a/src/lbc/utils.py b/src/lbc/utils.py index 245c529..4a58c5a 100644 --- a/src/lbc/utils.py +++ b/src/lbc/utils.py @@ -1,4 +1,4 @@ -from .models import Category, AdType, OwnerType, Sort, Region, Department, City +from .model import Category, AdType, OwnerType, Sort, Region, Department, City from .exceptions import InvalidValue from typing import Optional, Union, List