mirror of
https://github.com/etienne-hd/lbc.git
synced 2026-05-01 03:06:28 +02:00
add support for searching with full Leboncoin URLs and new shippable argument
This commit is contained in:
10
CHANGELOG.md
10
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
|
## 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
|
||||||
14
README.md
14
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
|
### 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:
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
156
src/lbc/utils.py
156
src/lbc/utils.py
@@ -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"]:
|
||||||
|
|||||||
@@ -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:
|
||||||
16
tests/test_search_with_url.py
Normal file
16
tests/test_search_with_url.py
Normal 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()
|
||||||
Reference in New Issue
Block a user