add support for searching with full Leboncoin URLs and new shippable argument

This commit is contained in:
etienne-hd
2025-06-21 00:07:09 +02:00
parent 47580f9321
commit fb9bca527d
7 changed files with 215 additions and 12 deletions

View File

@@ -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 ## 1.0.1
### Added ### Added
* Realistic `Sec-Fetch-*` headers to prevent 403 errors. * Realistic `Sec-Fetch-*` headers to prevent 403 errors
## 1.0.0 ## 1.0.0
* Initial release * Initial release

View File

@@ -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 ### Location
The `locations` parameter accepts a list of one or more location objects. You can use one of the following: The `locations` parameter accepts a list of one or more location objects. You can use one of the following:

View File

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

View File

@@ -1,7 +1,7 @@
from .session import Session from .session import Session
from .models import Proxy, Search, Category, AdType, OwnerType, Sort, Region, Department, City from .models import Proxy, Search, Category, AdType, OwnerType, Sort, Region, Department, City
from .exceptions import DatadomeError, RequestError 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 from typing import Optional, List, Union
@@ -44,6 +44,7 @@ class Client(Session):
def search( def search(
self, self,
url: Optional[str] = None,
text: Optional[str] = None, text: Optional[str] = None,
category: Category = Category.TOUTES_CATEGORIES, category: Category = Category.TOUTES_CATEGORIES,
sort: Sort = Sort.RELEVANCE, sort: Sort = Sort.RELEVANCE,
@@ -53,13 +54,19 @@ class Client(Session):
page: int = 1, page: int = 1,
ad_type: AdType = AdType.OFFER, ad_type: AdType = AdType.OFFER,
owner_type: Optional[OwnerType] = None, owner_type: Optional[OwnerType] = None,
shippable: Optional[bool] = None,
search_in_title_only: bool = False, search_in_title_only: bool = False,
**kwargs **kwargs
) -> Search: ) -> Search:
""" """
Perform a classified ads search on Leboncoin with the specified criteria. 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: 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. 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. 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. 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. 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. 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. 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. 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. **kwargs: Additional advanced filters such as price range (`price=(min, max)`), surface area (`square=(min, max)`), property type, and more.
Returns: Returns:
Search: A `Search` object containing the parsed search results. Search: A `Search` object containing the parsed search results.
""" """
payload = build_search_payload_with_args( if url:
text=text, category=category, sort=sort, locations=locations, payload = build_search_payload_with_url(
limit=limit, limit_alu=limit_alu, page=page, ad_type=ad_type, url=url, limit=limit, page=page
owner_type=owner_type, search_in_title_only=search_in_title_only, **kwargs )
) 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) body = self._fetch(method="POST", url="https://api.leboncoin.fr/finder/search", payload=payload)
return Search.build(raw=body) return Search.build(raw=body)

View File

@@ -3,6 +3,154 @@ from .exceptions import InvalidValue
from typing import Optional, Union, List 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( def build_search_payload_with_args(
text: Optional[str] = None, text: Optional[str] = None,
category: Category = Category.TOUTES_CATEGORIES, category: Category = Category.TOUTES_CATEGORIES,
@@ -13,6 +161,7 @@ def build_search_payload_with_args(
page: int = 1, page: int = 1,
ad_type: AdType = AdType.OFFER, ad_type: AdType = AdType.OFFER,
owner_type: Optional[OwnerType] = None, owner_type: Optional[OwnerType] = None,
shippable: Optional[bool] = False,
search_in_title_only: bool = False, search_in_title_only: bool = False,
**kwargs **kwargs
) -> dict: ) -> dict:
@@ -99,7 +248,10 @@ def build_search_payload_with_args(
# Search in title only # Search in title only
if text: if text:
if search_in_title_only: if search_in_title_only:
payload["filters"]["keywords"]["type"] = "subject" payload["filters"]["keywords"]["type"] = "subject"
if shippable:
payload["filters"]["locations"]["shippable"] = True
if kwargs: if kwargs:
for key, value in kwargs.items(): 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.") raise InvalidValue(f"The value of '{key}' must be a list or a tuple.")
# Range # Range
if all(isinstance(x, int) for x in value): 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.") raise InvalidValue(f"The value of '{key}' must be a list or tuple with at least two elements.")
if not "ranges" in payload["filters"]: if not "ranges" in payload["filters"]:

View File

@@ -22,8 +22,8 @@ def main() -> None:
category=lbc.Category.IMMOBILIER, category=lbc.Category.IMMOBILIER,
owner_type=lbc.OwnerType.ALL, owner_type=lbc.OwnerType.ALL,
search_in_title_only=True, search_in_title_only=True,
square=[200, 400], square=(200, 400),
price=[300_000, 700_000], price=[300_000, 700_000]
) )
for ad in result.ads: for ad in result.ads:

View File

@@ -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()