From fb9bca527d8e2b92d133ff604684bc3c6fc95f56 Mon Sep 17 00:00:00 2001 From: etienne-hd Date: Sat, 21 Jun 2025 00:07:09 +0200 Subject: [PATCH] add support for searching with full Leboncoin URLs and new shippable argument --- CHANGELOG.md | 10 +- README.md | 14 ++ pyproject.toml | 2 +- src/lbc/client.py | 25 ++- src/lbc/utils.py | 156 +++++++++++++++++- ...est_search.py => test_search_with_args.py} | 4 +- tests/test_search_with_url.py | 16 ++ 7 files changed, 215 insertions(+), 12 deletions(-) rename tests/{test_search.py => test_search_with_args.py} (91%) create mode 100644 tests/test_search_with_url.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 929ee43..470839a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ +## 1.0.2 +### Added +* Support for full Leboncoin URL in `client.search(url=...)` +* New `shippable` argument in the `search` function + +### Fixed +* Incorrect enum key assignment in the `build_search_payload_with_args` function + ## 1.0.1 ### Added -* Realistic `Sec-Fetch-*` headers to prevent 403 errors. +* Realistic `Sec-Fetch-*` headers to prevent 403 errors ## 1.0.0 * Initial release \ No newline at end of file diff --git a/README.md b/README.md index c7cd1ce..9df27d8 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,20 @@ client.search( ) ``` +#### Alternatively + +You can also perform search using a full Leboncoin URL: + +```python +client.search( + url="https://www.leboncoin.fr/recherche?category=9&text=maison&locations=Paris__48.86023250788424_2.339006433295173_9256&square=100-200price=500000-1000000&rooms=1-6&bedrooms=3-6&outside_access=garden,terrace&orientation=south_west&owner_type=private", + page=1, + limit=35 +) +``` + +If `url` is provided, it overrides other keyword parameters such as `text`, `category`, `locations`, etc. However, pagination parameters like `page`, `limit`, and `limit_alu` are still applied. + ### Location The `locations` parameter accepts a list of one or more location objects. You can use one of the following: diff --git a/pyproject.toml b/pyproject.toml index 0a899f4..00cbc43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lbc" -version = "1.0.1" +version = "1.0.2" description = "Unofficial client for Leboncoin API" readme = "README.md" license = {text = "MIT"} diff --git a/src/lbc/client.py b/src/lbc/client.py index 47b8371..c88676b 100644 --- a/src/lbc/client.py +++ b/src/lbc/client.py @@ -1,7 +1,7 @@ from .session import Session from .models import Proxy, Search, Category, AdType, OwnerType, Sort, Region, Department, City from .exceptions import DatadomeError, RequestError -from .utils import build_search_payload_with_args +from .utils import build_search_payload_with_args, build_search_payload_with_url from typing import Optional, List, Union @@ -44,6 +44,7 @@ class Client(Session): def search( self, + url: Optional[str] = None, text: Optional[str] = None, category: Category = Category.TOUTES_CATEGORIES, sort: Sort = Sort.RELEVANCE, @@ -53,13 +54,19 @@ class Client(Session): 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. @@ -69,17 +76,23 @@ class Client(Session): 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. """ - 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, search_in_title_only=search_in_title_only, **kwargs - ) + 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) return Search.build(raw=body) diff --git a/src/lbc/utils.py b/src/lbc/utils.py index 853d639..3b42fcb 100644 --- a/src/lbc/utils.py +++ b/src/lbc/utils.py @@ -3,6 +3,154 @@ from .exceptions import InvalidValue from typing import Optional, Union, List +def build_search_payload_with_url( + url: str, + limit: int = 35, + limit_alu: int = 3, + page: int = 1 +): + def build_area(area_values: list[str]) -> dict: + area = { + "lat": float(area_values[0]), + "lng": float(area_values[1]) + } + if len(area_values) >= 3: + area["default_radius"] = int(area_values[2]) + if len(area_values) >= 4: + area["radius"] = int(area_values[3]) + return area + + payload = { + "filters": {}, + "limit": limit, + "limit_alu": limit_alu, + "offset": limit * (page - 1), + "disable_total": True, + "extend": True, + "listing_source": "direct-search" if page == 1 else "pagination" + } + + args: List[str] = url.split("?")[1].split("&") + for arg in args: + key, value = arg.split("=") # e.g: real_estate_type 3,4 / square 300-400 / category 9 + + match key: + case "text": + payload["filters"]["keywords"] = { + "text": value + } + + case "category": + payload["filters"]["category"] = { + "id": value + } + + case "locations": + payload["filters"]["location"] = { + "locations": [] + } + + locations = value.split(",") + for location in locations: + location_parts = location.split("__") # City ['Paris', '48.86023250788424_2.339006433295173_9256'], Department ['d_69'], Region ['r_18'] or Place ['p_give a star if you like it!', '0.1234567891234_-0.1234567891234567_5000_5500'] + + prefix_parts = location_parts[0].split("_") + if len(prefix_parts) == 2: # Department ['d', '1'], Region ['r', '1'], or Place ['p', 'give a star if you like it!'] + location_id = prefix_parts[1] # Department '1', Region '1' or Place 'give a star if you like it!' + match prefix_parts[0]: + case "d": # Department + payload["filters"]["location"]["locations"].append( + { + "locationType": "department", + "department_id": location_id + } + ) + case "r": # Region + payload["filters"]["location"]["locations"].append( + { + "locationType": "region", + "region_id": location_id + } + ) + case "p": # Place + area_values = location_parts[1].split("_") # lat, lng, default_radius, radius + payload["filters"]["location"]["locations"].append( + { + "locationType": "place", + "place": location_id, + "label": location_id, + "area": build_area(area_values) + } + ) + case _: + raise InvalidValue(f"Unknown location type: {prefix_parts[0]}") + + else: # City + area_values = location_parts[1].split("_") # lat, lng, default_radius, radius + payload["filters"]["location"]["locations"].append( + { + "locationType": "city", + "city": location_parts[0], + "area": build_area(area_values) + } + ) + + case "order": + payload["sort_order"] = value + + case "sort": + payload["sort_by"] = value + + case "owner_type": + payload["owner_type"] = value + + case "shippable": + if value == "1": + payload["filters"]["location"]["shippable"] = True + + case _: + if value in ["page"]: # Pass + continue + + # Range or Enum + elif len(value.split("-")) == 2: # Range + range_values = value.split("-", 1) + if len(range_values) == 2: + min_val, max_val = range_values + + try: + min_val = int(min_val) + except ValueError: + min_val = None + + try: + max_val = int(max_val) + except ValueError: + max_val = None + + if not payload["filters"].get("ranges"): + payload["filters"]["ranges"] = {} + + if not payload["filters"].get("ranges"): + payload["filters"]["ranges"] = {} + + ranges = {} + if min_val is not None: + ranges["min"] = min_val + if max_val is not None: + ranges["max"] = max_val + + if ranges: + payload["filters"]["ranges"][key] = ranges + + else: # Enum + if not payload["filters"].get("enums"): + payload["filters"]["enums"] = {} + + payload["filters"]["enums"][key] = value.split(",") + + return payload + def build_search_payload_with_args( text: Optional[str] = None, category: Category = Category.TOUTES_CATEGORIES, @@ -13,6 +161,7 @@ def build_search_payload_with_args( page: int = 1, ad_type: AdType = AdType.OFFER, owner_type: Optional[OwnerType] = None, + shippable: Optional[bool] = False, search_in_title_only: bool = False, **kwargs ) -> dict: @@ -99,7 +248,10 @@ def build_search_payload_with_args( # Search in title only if text: if search_in_title_only: - payload["filters"]["keywords"]["type"] = "subject" + payload["filters"]["keywords"]["type"] = "subject" + + if shippable: + payload["filters"]["locations"]["shippable"] = True if kwargs: for key, value in kwargs.items(): @@ -107,7 +259,7 @@ def build_search_payload_with_args( raise InvalidValue(f"The value of '{key}' must be a list or a tuple.") # Range if all(isinstance(x, int) for x in value): - if len(value) == 1: + if len(value) <= 1: raise InvalidValue(f"The value of '{key}' must be a list or tuple with at least two elements.") if not "ranges" in payload["filters"]: diff --git a/tests/test_search.py b/tests/test_search_with_args.py similarity index 91% rename from tests/test_search.py rename to tests/test_search_with_args.py index 12c699c..f6505f8 100644 --- a/tests/test_search.py +++ b/tests/test_search_with_args.py @@ -22,8 +22,8 @@ def main() -> None: category=lbc.Category.IMMOBILIER, owner_type=lbc.OwnerType.ALL, search_in_title_only=True, - square=[200, 400], - price=[300_000, 700_000], + square=(200, 400), + price=[300_000, 700_000] ) for ad in result.ads: diff --git a/tests/test_search_with_url.py b/tests/test_search_with_url.py new file mode 100644 index 0000000..eabead4 --- /dev/null +++ b/tests/test_search_with_url.py @@ -0,0 +1,16 @@ +import lbc + +def main() -> None: + client = lbc.Client() + + result = client.search( + url="https://www.leboncoin.fr/recherche?category=9&text=maison&locations=Paris__48.86023250788424_2.339006433295173_9256&square=100-200&price=500000-1000000&rooms=1-6&bedrooms=3-6&outside_access=garden,terrace&orientation=south_west&owner_type=private", + page=1, + limit=35 + ) + + for ad in result.ads: + print(ad.id, ad.url, ad.subject, ad.price) + +if __name__ == "__main__": + main() \ No newline at end of file