mirror of
https://github.com/etienne-hd/lbc.git
synced 2026-04-23 15:25:36 +02:00
Initial release
This commit is contained in:
194
.gitignore
vendored
Normal file
194
.gitignore
vendored
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Abstra
|
||||||
|
# Abstra is an AI-powered process automation framework.
|
||||||
|
# Ignore directories containing user credentials, local state, and settings.
|
||||||
|
# Learn more at https://abstra.io/docs
|
||||||
|
.abstra/
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||||
|
# 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/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Cursor
|
||||||
|
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||||
|
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||||
|
# refer to https://docs.cursor.com/context/ignore-files
|
||||||
|
.cursorignore
|
||||||
|
.cursorindexingignore
|
||||||
2
CHANGELOG.md
Normal file
2
CHANGELOG.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
## v1.0.0
|
||||||
|
* Initial release
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Étienne Hodé
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
149
README.md
Normal file
149
README.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# lbc
|
||||||
|
[](https://pypi.org/project/lbc)
|
||||||
|

|
||||||
|
[](https://github.com/etienne-hd/lbc/blob/master/LICENSE)
|
||||||
|
|
||||||
|
**Unofficial client for Leboncoin API**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import lbc
|
||||||
|
|
||||||
|
client = lbc.Client()
|
||||||
|
|
||||||
|
location = lbc.City(
|
||||||
|
lat=48.85994982004764,
|
||||||
|
lng=2.33801967847424,
|
||||||
|
radius=10_000, # 10 km
|
||||||
|
city="Paris"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.search(
|
||||||
|
text="maison",
|
||||||
|
locations=[location],
|
||||||
|
page=1,
|
||||||
|
limit=35,
|
||||||
|
sort=lbc.Sort.NEWEST,
|
||||||
|
ad_type=lbc.AdType.OFFER,
|
||||||
|
category=lbc.Category.IMMOBILIER,
|
||||||
|
square=[200, 400],
|
||||||
|
price=[300_000, 700_000]
|
||||||
|
)
|
||||||
|
|
||||||
|
for ad in result.ads:
|
||||||
|
print(ad.url, ad.subject, ad.price)
|
||||||
|
```
|
||||||
|
*lbc is not affiliated with, endorsed by, or in any way associated with Leboncoin or its services. Use at your own risk.*
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
Required Python 3.9+
|
||||||
|
```bash
|
||||||
|
pip install lbc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
### Client
|
||||||
|
To create client you need to use lbc.Client class
|
||||||
|
```python
|
||||||
|
import lbc
|
||||||
|
|
||||||
|
client = lbc.Client()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Proxy
|
||||||
|
You can also configure the client to use a proxy by providing a `Proxy` object:
|
||||||
|
```python
|
||||||
|
proxy = lbc.Proxy(
|
||||||
|
host=...,
|
||||||
|
port=...,
|
||||||
|
username=...,
|
||||||
|
password=...
|
||||||
|
)
|
||||||
|
client = lbc.Client(proxy=proxy)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
To perform a search, use the `client.search` method.
|
||||||
|
|
||||||
|
This function accepts keyword arguments (`**kwargs`) to customize your query.
|
||||||
|
For example, if you're looking for houses that include both land and parking, you can specify:
|
||||||
|
|
||||||
|
```python
|
||||||
|
real_estate_type=["3", "4"]
|
||||||
|
```
|
||||||
|
|
||||||
|
These values correspond to what you’d find in a typical Leboncoin URL, like:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://www.leboncoin.fr/recherche?category=9&text=maison&...&real_estate_type=3,4
|
||||||
|
```
|
||||||
|
|
||||||
|
Here's a complete example of a search query:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client.search(
|
||||||
|
text="maison",
|
||||||
|
locations=[location],
|
||||||
|
page=1,
|
||||||
|
limit=35,
|
||||||
|
limit_alu=0,
|
||||||
|
sort=lbc.Sort.NEWEST,
|
||||||
|
ad_type=lbc.AdType.OFFER,
|
||||||
|
category=lbc.Category.IMMOBILIER,
|
||||||
|
owner_type=lbc.OwnerType.ALL,
|
||||||
|
search_in_title_only=True,
|
||||||
|
square=[200, 400],
|
||||||
|
price=[300_000, 700_000],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
The `locations` parameter accepts a list of one or more location objects. You can use one of the following:
|
||||||
|
|
||||||
|
* `lbc.Region(...)`
|
||||||
|
* `lbc.Department(...)`
|
||||||
|
* `lbc.City(...)`
|
||||||
|
|
||||||
|
Each one corresponds to a different level of geographic granularity.
|
||||||
|
|
||||||
|
#### City example
|
||||||
|
|
||||||
|
```python
|
||||||
|
location = lbc.City(
|
||||||
|
lat=48.85994982004764,
|
||||||
|
lng=2.33801967847424,
|
||||||
|
radius=10_000, # in meters
|
||||||
|
city="Paris"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Region / Department example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lbc import Region, Department
|
||||||
|
|
||||||
|
region = Region.ILE_DE_FRANCE
|
||||||
|
department = Department.PARIS
|
||||||
|
```
|
||||||
|
|
||||||
|
### 403 Error
|
||||||
|
|
||||||
|
If you encounter a **403 Forbidden** error, it usually means your requests are being blocked by [Datadome](https://datadome.co).
|
||||||
|
To resolve this:
|
||||||
|
|
||||||
|
* Try reducing the request frequency (add delays between requests).
|
||||||
|
* If you're using a proxy, make sure it is **clean** and preferably located in **France**.
|
||||||
|
|
||||||
|
Using residential or mobile proxies can also help avoid detection.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
<a href="https://www.buymeacoffee.com/etienneh" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||||
|
|
||||||
|
You can contact me via [Telegram](https://t.me/etienne_hd) or [Discord](https://discord.com/users/1153975318990827552) if you need help with scraping services or want to write a library.
|
||||||
29
pyproject.toml
Normal file
29
pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[project]
|
||||||
|
name = "lbc"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Unofficial client for Leboncoin API"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
|
||||||
|
authors = [
|
||||||
|
{name = "Etienne HODE", email = "hode.etienne@gmail.com"}
|
||||||
|
]
|
||||||
|
maintainers = [
|
||||||
|
{name = "Etienne HODE", email = "hode.etienne@gmail.com"}
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"curl_cffi==0.11.3"
|
||||||
|
]
|
||||||
|
|
||||||
|
keywords = ["lbc", "leboncoin", "wrapper", "api"]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/etienne-hd/lbc"
|
||||||
|
Repository = "https://github.com/etienne-hd/lbc"
|
||||||
|
Changelog = "https://github.com/etienne-hd/lbc/blob/main/CHANGELOG.md"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
curl_cffi==0.11.3
|
||||||
2
src/lbc/__init__.py
Normal file
2
src/lbc/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from .client import Client
|
||||||
|
from .models import *
|
||||||
85
src/lbc/client.py
Normal file
85
src/lbc/client.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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 typing import Optional, List, Union
|
||||||
|
|
||||||
|
class Client(Session):
|
||||||
|
def __init__(self, proxy: Optional[Proxy] = None):
|
||||||
|
super().__init__(proxy=proxy)
|
||||||
|
|
||||||
|
def _fetch(self, method: str, url: str, payload: Optional[dict] = None, timeout: int = 30) -> dict:
|
||||||
|
"""
|
||||||
|
Internal method to send an HTTP request using the configured session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method (staticmethod): 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.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DatadomeError: Raised when the request is blocked by Datadome protection (HTTP 403).
|
||||||
|
RequestError: Raised for any other non-successful HTTP response.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Parsed JSON response from the server.
|
||||||
|
"""
|
||||||
|
response = self.session.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
json=payload,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
if response.ok:
|
||||||
|
return response.json()
|
||||||
|
elif response.status_code == 403:
|
||||||
|
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.")
|
||||||
|
else:
|
||||||
|
raise RequestError(f"Request failed with status code {response.status_code}.")
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
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,
|
||||||
|
search_in_title_only: bool = False,
|
||||||
|
**kwargs
|
||||||
|
) -> Search:
|
||||||
|
"""
|
||||||
|
Perform a classified ads search on Leboncoin with the specified criteria.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
body = self._fetch(method="POST", url="https://api.leboncoin.fr/finder/search", payload=payload)
|
||||||
|
return Search.build(raw=body)
|
||||||
14
src/lbc/exceptions.py
Normal file
14
src/lbc/exceptions.py
Normal file
@@ -0,0 +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."""
|
||||||
4
src/lbc/models/__init__.py
Normal file
4
src/lbc/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .proxy import Proxy
|
||||||
|
from .search import Search
|
||||||
|
from .enums import *
|
||||||
|
from .city import City
|
||||||
32
src/lbc/models/ad.py
Normal file
32
src/lbc/models/ad.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from .attribute import Attribute
|
||||||
|
from .location import Location
|
||||||
|
from .owner import Owner
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ad:
|
||||||
|
id: int
|
||||||
|
first_publication_date: datetime
|
||||||
|
expiration_date: datetime
|
||||||
|
index_date: datetime
|
||||||
|
status: str
|
||||||
|
category_id: str
|
||||||
|
category_name: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
brand: str
|
||||||
|
ad_type: str
|
||||||
|
url: str
|
||||||
|
price: float
|
||||||
|
images: List[str]
|
||||||
|
attributes: List[Attribute]
|
||||||
|
location: Location
|
||||||
|
owner: Owner
|
||||||
|
has_phone: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str:
|
||||||
|
return self.subject
|
||||||
13
src/lbc/models/attribute.py
Normal file
13
src/lbc/models/attribute.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Attribute:
|
||||||
|
key: str
|
||||||
|
key_label: Optional[str]
|
||||||
|
value: str
|
||||||
|
value_label: str
|
||||||
|
values: List[str]
|
||||||
|
values_label: Optional[List[str]]
|
||||||
|
value_label_reader: Optional[str]
|
||||||
|
generic: bool
|
||||||
9
src/lbc/models/city.py
Normal file
9
src/lbc/models/city.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class City:
|
||||||
|
lat: float
|
||||||
|
lng: float
|
||||||
|
radius: int = 10_000
|
||||||
|
city: Optional[str] = None
|
||||||
267
src/lbc/models/enums.py
Normal file
267
src/lbc/models/enums.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import Union, Tuple
|
||||||
|
|
||||||
|
class OwnerType(Enum):
|
||||||
|
PRO = "pro"
|
||||||
|
PRIVATE = "private"
|
||||||
|
ALL = "all"
|
||||||
|
|
||||||
|
class AdType(Enum):
|
||||||
|
OFFER = "offer"
|
||||||
|
DEMAND = "demand"
|
||||||
|
|
||||||
|
class Sort(Enum):
|
||||||
|
RELEVANCE = ("relevance", None)
|
||||||
|
NEWEST = ("time", "desc")
|
||||||
|
OLDEST = ("time", "asc")
|
||||||
|
EXPENSIVE = ("price", "asc")
|
||||||
|
CHEAPEST = ("price", "desc")
|
||||||
|
|
||||||
|
class Department(Enum):
|
||||||
|
BAS_RHIN = ("1", "ALSACE", "67", "BAS_RHIN")
|
||||||
|
HAUT_RHIN = ("1", "ALSACE", "68", "HAUT_RHIN")
|
||||||
|
DORDOGNE = ("2", "AQUITAINE", "24", "DORDOGNE")
|
||||||
|
GIRONDE = ("2", "AQUITAINE", "33", "GIRONDE")
|
||||||
|
LANDES = ("2", "AQUITAINE", "40", "LANDES")
|
||||||
|
LOT_ET_GARONNE = ("2", "AQUITAINE", "47", "LOT_ET_GARONNE")
|
||||||
|
PYRENEES_ATLANTIQUES = ("2", "AQUITAINE", "64", "PYRENEES_ATLANTIQUES")
|
||||||
|
ALLIER = ("3", "AUVERGNE", "3", "ALLIER")
|
||||||
|
CANTAL = ("3", "AUVERGNE", "15", "CANTAL")
|
||||||
|
HAUTE_LOIRE = ("3", "AUVERGNE", "43", "HAUTE_LOIRE")
|
||||||
|
PUY_DE_DOME = ("3", "AUVERGNE", "63", "PUY_DE_DOME")
|
||||||
|
CALVADOS = ("4", "BASSE_NORMANDIE", "14", "CALVADOS")
|
||||||
|
MANCHE = ("4", "BASSE_NORMANDIE", "50", "MANCHE")
|
||||||
|
ORNE = ("4", "BASSE_NORMANDIE", "61", "ORNE")
|
||||||
|
COTE_DOR = ("5", "BOURGOGNE", "21", "COTE_DOR")
|
||||||
|
NIEVRE = ("5", "BOURGOGNE", "58", "NIEVRE")
|
||||||
|
SAONE_ET_LOIRE = ("5", "BOURGOGNE", "71", "SAONE_ET_LOIRE")
|
||||||
|
YONNE = ("5", "BOURGOGNE", "89", "YONNE")
|
||||||
|
COTES_DARMOR = ("6", "BRETAGNE", "22", "COTES_DARMOR")
|
||||||
|
FINISTERE = ("6", "BRETAGNE", "29", "FINISTERE")
|
||||||
|
ILLE_ET_VILAINE = ("6", "BRETAGNE", "35", "ILLE_ET_VILAINE")
|
||||||
|
MORBIHAN = ("6", "BRETAGNE", "56", "MORBIHAN")
|
||||||
|
CHER = ("7", "CENTRE", "18", "CHER")
|
||||||
|
EURE_ET_LOIR = ("7", "CENTRE", "28", "EURE_ET_LOIR")
|
||||||
|
INDRE = ("7", "CENTRE", "36", "INDRE")
|
||||||
|
INDRE_ET_LOIRE = ("7", "CENTRE", "37", "INDRE_ET_LOIRE")
|
||||||
|
LOIR_ET_CHER = ("7", "CENTRE", "41", "LOIR_ET_CHER")
|
||||||
|
LOIRET = ("7", "CENTRE", "45", "LOIRET")
|
||||||
|
ARDENNES = ("8", "CHAMPAGNE_ARDENNE", "8", "ARDENNES")
|
||||||
|
AUBE = ("8", "CHAMPAGNE_ARDENNE", "10", "AUBE")
|
||||||
|
MARNE = ("8", "CHAMPAGNE_ARDENNE", "51", "MARNE")
|
||||||
|
HAUTE_MARNE = ("8", "CHAMPAGNE_ARDENNE", "52", "HAUTE_MARNE")
|
||||||
|
DOUBS = ("10", "FRANCHE_COMTE", "25", "DOUBS")
|
||||||
|
JURA = ("10", "FRANCHE_COMTE", "39", "JURA")
|
||||||
|
HAUTE_SAONE = ("10", "FRANCHE_COMTE", "70", "HAUTE_SAONE")
|
||||||
|
TERRITOIRE_DE_BELFORT = ("10", "FRANCHE_COMTE", "90", "TERRITOIRE_DE_BELFORT")
|
||||||
|
EURE = ("11", "HAUTE_NORMANDIE", "27", "EURE")
|
||||||
|
SEINE_MARITIME = ("11", "HAUTE_NORMANDIE", "76", "SEINE_MARITIME")
|
||||||
|
PARIS = ("12", "ILE_DE_FRANCE", "75", "PARIS")
|
||||||
|
SEINE_ET_MARNE = ("12", "ILE_DE_FRANCE", "77", "SEINE_ET_MARNE")
|
||||||
|
YVELINES = ("12", "ILE_DE_FRANCE", "78", "YVELINES")
|
||||||
|
ESSONNE = ("12", "ILE_DE_FRANCE", "91", "ESSONNE")
|
||||||
|
HAUTS_DE_SEINE = ("12", "ILE_DE_FRANCE", "92", "HAUTS_DE_SEINE")
|
||||||
|
SEINE_SAINT_DENIS = ("12", "ILE_DE_FRANCE", "93", "SEINE_SAINT_DENIS")
|
||||||
|
VAL_DE_MARNE = ("12", "ILE_DE_FRANCE", "94", "VAL_DE_MARNE")
|
||||||
|
VAL_DOISE = ("12", "ILE_DE_FRANCE", "95", "VAL_DOISE")
|
||||||
|
AUDE = ("13", "LANGUEDOC_ROUSSILLON", "11", "AUDE")
|
||||||
|
GARD = ("13", "LANGUEDOC_ROUSSILLON", "30", "GARD")
|
||||||
|
HERAULT = ("13", "LANGUEDOC_ROUSSILLON", "34", "HERAULT")
|
||||||
|
LOZERE = ("13", "LANGUEDOC_ROUSSILLON", "48", "LOZERE")
|
||||||
|
PYRENEES_ORIENTALES = ("13", "LANGUEDOC_ROUSSILLON", "66", "PYRENEES_ORIENTALES")
|
||||||
|
CORREZE = ("14", "LIMOUSIN", "19", "CORREZE")
|
||||||
|
CREUSE = ("14", "LIMOUSIN", "23", "CREUSE")
|
||||||
|
HAUTE_VIENNE = ("14", "LIMOUSIN", "87", "HAUTE_VIENNE")
|
||||||
|
MEURTHE_ET_MOSELLE = ("15", "LORRAINE", "54", "MEURTHE_ET_MOSELLE")
|
||||||
|
MEUSE = ("15", "LORRAINE", "55", "MEUSE")
|
||||||
|
MOSELLE = ("15", "LORRAINE", "57", "MOSELLE")
|
||||||
|
VOSGES = ("15", "LORRAINE", "88", "VOSGES")
|
||||||
|
ARIEGE = ("16", "MIDI_PYRENEES", "9", "ARIEGE")
|
||||||
|
AVEYRON = ("16", "MIDI_PYRENEES", "12", "AVEYRON")
|
||||||
|
HAUTE_GARONNE = ("16", "MIDI_PYRENEES", "31", "HAUTE_GARONNE")
|
||||||
|
GERS = ("16", "MIDI_PYRENEES", "32", "GERS")
|
||||||
|
LOT = ("16", "MIDI_PYRENEES", "46", "LOT")
|
||||||
|
HAUTES_PYRENEES = ("16", "MIDI_PYRENEES", "65", "HAUTES_PYRENEES")
|
||||||
|
TARN = ("16", "MIDI_PYRENEES", "81", "TARN")
|
||||||
|
TARN_ET_GARONNE = ("16", "MIDI_PYRENEES", "82", "TARN_ET_GARONNE")
|
||||||
|
NORD = ("17", "NORD_PAS_DE_CALAIS", "59", "NORD")
|
||||||
|
PAS_DE_CALAIS = ("17", "NORD_PAS_DE_CALAIS", "62", "PAS_DE_CALAIS")
|
||||||
|
LOIRE_ATLANTIQUE = ("18", "PAYS_DE_LA_LOIRE", "44", "LOIRE_ATLANTIQUE")
|
||||||
|
MAINE_ET_LOIRE = ("18", "PAYS_DE_LA_LOIRE", "49", "MAINE_ET_LOIRE")
|
||||||
|
MAYENNE = ("18", "PAYS_DE_LA_LOIRE", "53", "MAYENNE")
|
||||||
|
SARTHE = ("18", "PAYS_DE_LA_LOIRE", "72", "SARTHE")
|
||||||
|
VENDEE = ("18", "PAYS_DE_LA_LOIRE", "85", "VENDEE")
|
||||||
|
AISNE = ("19", "PICARDIE", "2", "AISNE")
|
||||||
|
OISE = ("19", "PICARDIE", "60", "OISE")
|
||||||
|
SOMME = ("19", "PICARDIE", "80", "SOMME")
|
||||||
|
CHARENTE = ("20", "POITOU_CHARENTES", "16", "CHARENTE")
|
||||||
|
CHARENTE_MARITIME = ("20", "POITOU_CHARENTES", "17", "CHARENTE_MARITIME")
|
||||||
|
DEUX_SEVRES = ("20", "POITOU_CHARENTES", "79", "DEUX_SEVRES")
|
||||||
|
VIENNE = ("20", "POITOU_CHARENTES", "86", "VIENNE")
|
||||||
|
ALPES_DE_HAUTE_PROVENCE = ("21", "PROVENCE_ALPES_COTE_DAZUR", "4", "ALPES_DE_HAUTE_PROVENCE")
|
||||||
|
HAUTES_ALPES = ("21", "PROVENCE_ALPES_COTE_DAZUR", "5", "HAUTES_ALPES")
|
||||||
|
ALPES_MARITIMES = ("21", "PROVENCE_ALPES_COTE_DAZUR", "6", "ALPES_MARITIMES")
|
||||||
|
BOUCHES_DU_RHONE = ("21", "PROVENCE_ALPES_COTE_DAZUR", "13", "BOUCHES_DU_RHONE")
|
||||||
|
VAR = ("21", "PROVENCE_ALPES_COTE_DAZUR", "83", "VAR")
|
||||||
|
VAUCLUSE = ("21", "PROVENCE_ALPES_COTE_DAZUR", "84", "VAUCLUSE")
|
||||||
|
AIN = ("22", "RHONE_ALPES", "1", "AIN")
|
||||||
|
ARDECHE = ("22", "RHONE_ALPES", "7", "ARDECHE")
|
||||||
|
DROME = ("22", "RHONE_ALPES", "26", "DROME")
|
||||||
|
ISERE = ("22", "RHONE_ALPES", "38", "ISERE")
|
||||||
|
LOIRE = ("22", "RHONE_ALPES", "42", "LOIRE")
|
||||||
|
RHONE = ("22", "RHONE_ALPES", "69", "RHONE")
|
||||||
|
SAVOIE = ("22", "RHONE_ALPES", "73", "SAVOIE")
|
||||||
|
HAUTE_SAVOIE = ("22", "RHONE_ALPES", "74", "HAUTE_SAVOIE")
|
||||||
|
|
||||||
|
class Region(Enum):
|
||||||
|
ALSACE = ("1", "ALSACE")
|
||||||
|
AQUITAINE = ("2", "AQUITAINE")
|
||||||
|
AUVERGNE = ("3", "AUVERGNE")
|
||||||
|
AUVERGNE_RHONE_ALPES = ("30", "AUVERGNE_RHONE_ALPES")
|
||||||
|
BASSE_NORMANDIE = ("4", "BASSE_NORMANDIE")
|
||||||
|
BOURGOGNE = ("5", "BOURGOGNE")
|
||||||
|
BOURGOGNE_FRANCHE_COMTE = ("31", "BOURGOGNE_FRANCHE_COMTE")
|
||||||
|
BRETAGNE = ("6", "BRETAGNE")
|
||||||
|
CENTRE = ("7", "CENTRE")
|
||||||
|
CENTRE_VAL_DE_LOIRE = ("37", "CENTRE_VAL_DE_LOIRE")
|
||||||
|
CHAMPAGNE_ARDENNE = ("8", "CHAMPAGNE_ARDENNE")
|
||||||
|
CORSE = ("9", "CORSE")
|
||||||
|
FRANCHE_COMTE = ("10", "FRANCHE_COMTE")
|
||||||
|
GRAND_EST = ("33", "GRAND_EST")
|
||||||
|
GUADELOUPE = ("23", "GUADELOUPE")
|
||||||
|
GUYANE = ("25", "GUYANE")
|
||||||
|
HAUTE_NORMANDIE = ("11", "HAUTE_NORMANDIE")
|
||||||
|
HAUTS_DE_FRANCE = ("32", "HAUTS_DE_FRANCE")
|
||||||
|
ILE_DE_FRANCE = ("12", "ILE_DE_FRANCE")
|
||||||
|
LANGUEDOC_ROUSSILLON = ("13", "LANGUEDOC_ROUSSILLON")
|
||||||
|
LIMOUSIN = ("14", "LIMOUSIN")
|
||||||
|
LORRAINE = ("15", "LORRAINE")
|
||||||
|
MARTINIQUE = ("24", "MARTINIQUE")
|
||||||
|
MIDI_PYRENEES = ("16", "MIDI_PYRENEES")
|
||||||
|
NORD_PAS_DE_CALAIS = ("17", "NORD_PAS_DE_CALAIS")
|
||||||
|
NORMANDIE = ("34", "NORMANDIE")
|
||||||
|
NOUVELLE_AQUITAINE = ("35", "NOUVELLE_AQUITAINE")
|
||||||
|
OCCITANIE = ("36", "OCCITANIE")
|
||||||
|
PAYS_DE_LA_LOIRE = ("18", "PAYS_DE_LA_LOIRE")
|
||||||
|
PICARDIE = ("19", "PICARDIE")
|
||||||
|
POITOU_CHARENTES = ("20", "POITOU_CHARENTES")
|
||||||
|
PROVENCE_ALPES_COTE_DAZUR = ("21", "PROVENCE_ALPES_COTE_DAZUR")
|
||||||
|
RHONE_ALPES = ("22", "RHONE_ALPES")
|
||||||
|
REUNION = ("26", "REUNION")
|
||||||
|
|
||||||
|
class Category(Enum):
|
||||||
|
TOUTES_CATEGORIES = "0"
|
||||||
|
EMPLOI = "71"
|
||||||
|
EMPLOI_OFFRES_DEMPLOI = "33"
|
||||||
|
EMPLOI_FORMATIONS_PROFESSIONNELLES = "74"
|
||||||
|
VEHICULES = "1"
|
||||||
|
VEHICULES_VOITURES = "2"
|
||||||
|
VEHICULES_MOTOS = "3"
|
||||||
|
VEHICULES_CARAVANING = "4"
|
||||||
|
VEHICULES_UTILITAIRES = "5"
|
||||||
|
VEHICULES_CAMIONS = "300"
|
||||||
|
VEHICULES_NAUTISME = "7"
|
||||||
|
VEHICULES_VELOS = "1002"
|
||||||
|
VEHICULES_EQUIPEMENT_AUTO = "6"
|
||||||
|
VEHICULES_EQUIPEMENT_MOTO = "44"
|
||||||
|
VEHICULES_EQUIPEMENT_CARAVANING = "50"
|
||||||
|
VEHICULES_EQUIPEMENT_NAUTISME = "51"
|
||||||
|
VEHICULES_EQUIPEMENTS_VELOS = "1003"
|
||||||
|
VEHICULES_SERVICES_DE_REPARATIONS_MECANIQUES = "1004"
|
||||||
|
IMMOBILIER = "8"
|
||||||
|
IMMOBILIER_VENTES_IMMOBILIERES = "9"
|
||||||
|
IMMOBILIER_LOCATIONS = "10"
|
||||||
|
IMMOBILIER_COLOCATIONS = "11"
|
||||||
|
IMMOBILIER_BUREAUX_ET_COMMERCES = "13"
|
||||||
|
IMMOBILIER_IMMOBILIER_NEUF = "304"
|
||||||
|
IMMOBILIER_SERVICES_DE_DEMENAGEMENT = "1001"
|
||||||
|
LOCATIONS_DE_VACANCES = "66"
|
||||||
|
LOCATIONS_DE_VACANCES_LOCATIONS_SAISONNIERES = "12"
|
||||||
|
ELECTRONIQUE = "14"
|
||||||
|
ELECTRONIQUE_ORDINATEURS = "15"
|
||||||
|
ELECTRONIQUE_ACCESSOIRES_INFORMATIQUE = "83"
|
||||||
|
ELECTRONIQUE_TABLETTES_ET_LISEUSES = "82"
|
||||||
|
ELECTRONIQUE_PHOTO_AUDIO_ET_VIDEO = "16"
|
||||||
|
ELECTRONIQUE_TELEPHONES_ET_OBJETS_CONNECTES = "17"
|
||||||
|
ELECTRONIQUE_ACCESSOIRES_TELEPHONE_ET_OBJETS_CONNECTES = "81"
|
||||||
|
ELECTRONIQUE_CONSOLES = "43"
|
||||||
|
ELECTRONIQUE_JEUX_VIDEO = "84"
|
||||||
|
ELECTRONIQUE_ELECTROMENAGER = "1006"
|
||||||
|
ELECTRONIQUE_SERVICES_DE_REPARATIONS_ELECTRONIQUES = "1007"
|
||||||
|
MAISON_ET_JARDIN = "18"
|
||||||
|
MAISON_ET_JARDIN_AMEUBLEMENT = "19"
|
||||||
|
MAISON_ET_JARDIN_PAPETERIE_ET_FOURNITURES_SCOLAIRES = "96"
|
||||||
|
MAISON_ET_JARDIN_ELECTROMENAGER = "20"
|
||||||
|
MAISON_ET_JARDIN_ARTS_DE_LA_TABLE = "45"
|
||||||
|
MAISON_ET_JARDIN_DECORATION = "39"
|
||||||
|
MAISON_ET_JARDIN_LINGE_DE_MAISON = "46"
|
||||||
|
MAISON_ET_JARDIN_BRICOLAGE = "21"
|
||||||
|
MAISON_ET_JARDIN_JARDIN_ET_PLANTES = "52"
|
||||||
|
MAISON_ET_JARDIN_SERVICES_DE_JARDINERIE_ET_BRICOLAGE = "1005"
|
||||||
|
FAMILLE = "79"
|
||||||
|
FAMILLE_EQUIPEMENT_BEBE = "23"
|
||||||
|
FAMILLE_MOBILIER_ENFANT = "80"
|
||||||
|
FAMILLE_VETEMENTS_BEBE = "54"
|
||||||
|
FAMILLE_VETEMENTS_ENFANTS = "1011"
|
||||||
|
FAMILLE_VETEMENTS_MATERNITE = "1012"
|
||||||
|
FAMILLE_CHAUSSURES_ENFANTS = "1013"
|
||||||
|
FAMILLE_MONTRES_ET_BIJOUX_ENFANTS = "1014"
|
||||||
|
FAMILLE_ACCESSOIRES_ET_BAGAGERIE_ENFANTS = "1015"
|
||||||
|
FAMILLE_JEUX_ET_JOUETS = "1016"
|
||||||
|
FAMILLE_BABY_SITTING = "1017"
|
||||||
|
MODE = "72"
|
||||||
|
MODE_VETEMENTS = "22"
|
||||||
|
MODE_CHAUSSURES = "53"
|
||||||
|
MODE_ACCESSOIRES_ET_BAGAGERIE = "47"
|
||||||
|
MODE_MONTRES_ET_BIJOUX = "42"
|
||||||
|
LOISIRS = "24"
|
||||||
|
LOISIRS_ANTIQUITES = "89"
|
||||||
|
LOISIRS_ARTISTES_ET_MUSICIENS = "1008"
|
||||||
|
LOISIRS_BILLETTERIE = "1009"
|
||||||
|
LOISIRS_COLLECTION = "40"
|
||||||
|
LOISIRS_CD_MUSIQUE = "26"
|
||||||
|
LOISIRS_DVD_FILMS = "25"
|
||||||
|
LOISIRS_INSTRUMENTS_DE_MUSIQUE = "30"
|
||||||
|
LOISIRS_LIVRES = "27"
|
||||||
|
LOISIRS_MODELISME = "86"
|
||||||
|
LOISIRS_VINS_ET_GASTRONOMIE = "48"
|
||||||
|
LOISIRS_JEUX_ET_JOUETS = "41"
|
||||||
|
LOISIRS_LOISIRS_CREATIFS = "88"
|
||||||
|
LOISIRS_SPORT_ET_PLEIN_AIR = "29"
|
||||||
|
LOISIRS_VELOS = "55"
|
||||||
|
LOISIRS_EQUIPEMENTS_VELOS = "85"
|
||||||
|
ANIMAUX = "75"
|
||||||
|
ANIMAUX_ANIMAUX = "28"
|
||||||
|
ANIMAUX_ACCESSOIRES_ANIMAUX = "76"
|
||||||
|
ANIMAUX_ANIMAUX_PERDUS = "77"
|
||||||
|
ANIMAUX_SERVICES_AUX_ANIMAUX = "1010"
|
||||||
|
MATERIEL_PROFESSIONNEL = "56"
|
||||||
|
MATERIEL_PROFESSIONNEL_TRACTEURS = "105"
|
||||||
|
MATERIEL_PROFESSIONNEL_MATERIEL_AGRICOLE = "57"
|
||||||
|
MATERIEL_PROFESSIONNEL_BTP_CHANTIER_GROS_OEUVRE = "59"
|
||||||
|
MATERIEL_PROFESSIONNEL_POIDS_LOURDS = "106"
|
||||||
|
MATERIEL_PROFESSIONNEL_MANUTENTION_LEVAGE = "58"
|
||||||
|
MATERIEL_PROFESSIONNEL_EQUIPEMENTS_INDUSTRIELS = "32"
|
||||||
|
MATERIEL_PROFESSIONNEL_EQUIPEMENTS_POUR_RESTAURANTS_ET_HOTELS = "61"
|
||||||
|
MATERIEL_PROFESSIONNEL_EQUIPEMENTS_ET_FOURNITURES_DE_BUREAU = "62"
|
||||||
|
MATERIEL_PROFESSIONNEL_EQUIPEMENTS_POUR_COMMERCES_ET_MARCHES = "63"
|
||||||
|
MATERIEL_PROFESSIONNEL_MATERIEL_MEDICAL = "64"
|
||||||
|
SERVICES = "31"
|
||||||
|
SERVICES_ARTISTES_ET_MUSICIENS = "101"
|
||||||
|
SERVICES_BABY_SITTING = "100"
|
||||||
|
SERVICES_BILLETTERIE = "35"
|
||||||
|
SERVICES_COVOITURAGE = "65"
|
||||||
|
SERVICES_COURS_PARTICULIERS = "36"
|
||||||
|
SERVICES_ENTRAIDE_ENTRE_VOISINS = "103"
|
||||||
|
SERVICES_EVENEMENTS = "49"
|
||||||
|
SERVICES_SERVICES_A_LA_PERSONNE = "99"
|
||||||
|
SERVICES_SERVICES_AUX_ANIMAUX = "102"
|
||||||
|
SERVICES_SERVICES_DE_DEMENAGEMENT = "92"
|
||||||
|
SERVICES_SERVICES_DE_REPARATIONS_ELECTRONIQUES = "95"
|
||||||
|
SERVICES_SERVICES_DE_REPARATIONS_MECANIQUES = "93"
|
||||||
|
SERVICES_SERVICES_DE_JARDINERIE_ET_BRICOLAGE = "97"
|
||||||
|
SERVICES_SERVICES_EVENEMENTIELS = "98"
|
||||||
|
SERVICES_AUTRES_SERVICES = "34"
|
||||||
|
DONS = "1000"
|
||||||
|
DIVERS = "37"
|
||||||
|
DIVERS_AUTRES = "38"
|
||||||
17
src/lbc/models/location.py
Normal file
17
src/lbc/models/location.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Location:
|
||||||
|
country_id: str
|
||||||
|
region_id: str
|
||||||
|
region_name: str
|
||||||
|
department_id: str
|
||||||
|
department_name: str
|
||||||
|
city_label: str
|
||||||
|
city: str
|
||||||
|
zipcode: str
|
||||||
|
lat: float
|
||||||
|
lng: float
|
||||||
|
source: str
|
||||||
|
provider: str
|
||||||
|
is_shape: bool
|
||||||
9
src/lbc/models/owner.py
Normal file
9
src/lbc/models/owner.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Owner:
|
||||||
|
store_id: str
|
||||||
|
user_id: str
|
||||||
|
type: str
|
||||||
|
name: str
|
||||||
|
no_salesmen: bool
|
||||||
16
src/lbc/models/proxy.py
Normal file
16
src/lbc/models/proxy.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Union, Optional
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Proxy:
|
||||||
|
host: str
|
||||||
|
port: Union[str, int]
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
if self.username and self.password:
|
||||||
|
return f"http://{self.username}:{self.password}@{self.host}:{self.port}"
|
||||||
|
else:
|
||||||
|
return f"http://{self.host}:{self.port}"
|
||||||
101
src/lbc/models/search.py
Normal file
101
src/lbc/models/search.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from .ad import Ad
|
||||||
|
from .attribute import Attribute
|
||||||
|
from .location import Location
|
||||||
|
from .owner import Owner
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Search:
|
||||||
|
total: int
|
||||||
|
total_all: int
|
||||||
|
total_pro: int
|
||||||
|
total_private: int
|
||||||
|
total_active: int
|
||||||
|
total_inactive: int
|
||||||
|
total_shippable: int
|
||||||
|
max_pages: int
|
||||||
|
ads: List[Ad]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build(raw: dict) -> "Search":
|
||||||
|
ads: List[Ad] = []
|
||||||
|
|
||||||
|
for raw_ad in raw.get("ads", []):
|
||||||
|
attributes: List[Attribute] = []
|
||||||
|
for raw_attribute in raw_ad.get("attributes", []):
|
||||||
|
attributes.append(
|
||||||
|
Attribute(
|
||||||
|
key=raw_attribute.get("key"),
|
||||||
|
key_label=raw_attribute.get("key_label"),
|
||||||
|
value=raw_attribute.get("value"),
|
||||||
|
value_label=raw_attribute.get("value_label"),
|
||||||
|
values=raw_attribute.get("values"),
|
||||||
|
values_label=raw_attribute.get("values_label"),
|
||||||
|
value_label_reader=raw_attribute.get("value_label_reader"),
|
||||||
|
generic=raw_attribute.get("generic")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_location: dict = raw.get("location", {})
|
||||||
|
location = Location(
|
||||||
|
country_id=raw_location.get("country_id"),
|
||||||
|
region_id=raw_location.get("region_id"),
|
||||||
|
region_name=raw_location.get("region_name"),
|
||||||
|
department_id=raw_location.get("department_id"),
|
||||||
|
department_name=raw_location.get("department_name"),
|
||||||
|
city_label=raw_location.get("city_label"),
|
||||||
|
city=raw_location.get("city"),
|
||||||
|
zipcode=raw_location.get("zipcode"),
|
||||||
|
lat=raw_location.get("lat"),
|
||||||
|
lng=raw_location.get("lng"),
|
||||||
|
source=raw_location.get("source"),
|
||||||
|
provider=raw_location.get("provider"),
|
||||||
|
is_shape=raw_location.get("is_shape")
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_owner: dict = raw.get("owner", {})
|
||||||
|
owner = Owner(
|
||||||
|
store_id=raw_owner.get("store_id"),
|
||||||
|
user_id=raw_owner.get("user_id"),
|
||||||
|
type=raw_owner.get("type"),
|
||||||
|
name=raw_owner.get("name"),
|
||||||
|
no_salesmen=raw_owner.get("no_salesmen")
|
||||||
|
)
|
||||||
|
|
||||||
|
ads.append(
|
||||||
|
Ad(
|
||||||
|
id=raw_ad.get("list_id"),
|
||||||
|
first_publication_date=datetime.strptime(raw_ad.get("first_publication_date"), "%Y-%m-%d %H:%M:%S") if raw_ad.get("first_publication_date") else None,
|
||||||
|
expiration_date=datetime.strptime(raw_ad.get("expiration_date"), "%Y-%m-%d %H:%M:%S") if raw_ad.get("expiration_date") else None,
|
||||||
|
index_date=datetime.strptime(raw_ad.get("index_date"), "%Y-%m-%d %H:%M:%S") if raw_ad.get("index_date") else None,
|
||||||
|
status=raw_ad.get("status"),
|
||||||
|
category_id=raw_ad.get("category_id"),
|
||||||
|
category_name=raw_ad.get("category_name"),
|
||||||
|
subject=raw_ad.get("subject"),
|
||||||
|
body=raw_ad.get("body"),
|
||||||
|
brand=raw_ad.get("brand"),
|
||||||
|
ad_type=raw_ad.get("ad_type"),
|
||||||
|
url=raw_ad.get("url"),
|
||||||
|
price=raw_ad.get("price_cents") / 100 if raw_ad.get("price_cents") else None,
|
||||||
|
images=raw_ad.get("images", {}).get("urls_large"),
|
||||||
|
attributes=attributes,
|
||||||
|
location=location,
|
||||||
|
owner=owner,
|
||||||
|
has_phone=raw_ad.get("has_phone")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Search(
|
||||||
|
total=raw.get("total"),
|
||||||
|
total_all=raw.get("total_all"),
|
||||||
|
total_pro=raw.get("total_pro"),
|
||||||
|
total_private=raw.get("total_private"),
|
||||||
|
total_active=raw.get("total_active"),
|
||||||
|
total_inactive=raw.get("total_inactive"),
|
||||||
|
total_shippable=raw.get("total_shippable"),
|
||||||
|
max_pages=raw.get("max_pages"),
|
||||||
|
ads=ads
|
||||||
|
)
|
||||||
39
src/lbc/session.py
Normal file
39
src/lbc/session.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from .models import Proxy
|
||||||
|
|
||||||
|
from curl_cffi import requests
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def __init__(self, proxy: Optional[Proxy] = None):
|
||||||
|
self._session = self._init_session(proxy=proxy)
|
||||||
|
self._proxy = proxy
|
||||||
|
|
||||||
|
def _init_session(self, proxy: Optional[Proxy] = None) -> requests.Session:
|
||||||
|
"""
|
||||||
|
Initializes an HTTP session with optional proxy and browser impersonation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
proxy (Optional[Proxy], optional): Proxy configuration to use for the session. If provided, it will be applied to both HTTP and HTTPS traffic.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
requests.Session: A configured session instance ready to send requests.
|
||||||
|
"""
|
||||||
|
session = requests.Session(
|
||||||
|
impersonate="firefox",
|
||||||
|
)
|
||||||
|
|
||||||
|
if proxy:
|
||||||
|
session.proxies = {
|
||||||
|
"http": proxy.url,
|
||||||
|
"https": proxy.url
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session(self):
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
@property
|
||||||
|
def proxy(self):
|
||||||
|
return self._proxy
|
||||||
126
src/lbc/utils.py
Normal file
126
src/lbc/utils.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from .models import Category, AdType, OwnerType, Sort, Region, Department, City
|
||||||
|
from .exceptions import InvalidValue
|
||||||
|
|
||||||
|
from typing import Optional, Union, List
|
||||||
|
|
||||||
|
def build_search_payload_with_args(
|
||||||
|
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,
|
||||||
|
search_in_title_only: bool = False,
|
||||||
|
**kwargs
|
||||||
|
) -> dict:
|
||||||
|
payload = {
|
||||||
|
"filters": {
|
||||||
|
"category": {
|
||||||
|
"id": category.value
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"ad_type": [
|
||||||
|
ad_type.value
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"keywords": {
|
||||||
|
"text": text
|
||||||
|
},
|
||||||
|
"location": {}
|
||||||
|
},
|
||||||
|
"limit": limit,
|
||||||
|
"limit_alu": limit_alu,
|
||||||
|
"offset": limit * (page - 1),
|
||||||
|
"disable_total": True,
|
||||||
|
"extend": True,
|
||||||
|
"listing_source": "direct-search" if page == 1 else "pagination"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Text
|
||||||
|
if text:
|
||||||
|
payload["filters"]["keywords"] = {
|
||||||
|
"text": text
|
||||||
|
}
|
||||||
|
|
||||||
|
# Owner Type
|
||||||
|
if owner_type:
|
||||||
|
payload["owner_type"] = owner_type.value
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
sort_by, sort_order = sort.value
|
||||||
|
payload["sort_by"] = sort_by
|
||||||
|
if sort_order:
|
||||||
|
payload["sort_order"] = sort_order
|
||||||
|
|
||||||
|
# Location
|
||||||
|
if locations and not isinstance(locations, list):
|
||||||
|
locations = [locations]
|
||||||
|
|
||||||
|
if locations:
|
||||||
|
payload["filters"]["location"] = {
|
||||||
|
"locations": []
|
||||||
|
}
|
||||||
|
for location in locations:
|
||||||
|
match location:
|
||||||
|
case Region():
|
||||||
|
payload["filters"]["location"]["locations"].append(
|
||||||
|
{
|
||||||
|
"locationType": "region",
|
||||||
|
"region_id": location.value[0]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case Department():
|
||||||
|
payload["filters"]["location"]["locations"].append(
|
||||||
|
{
|
||||||
|
"locationType": "department",
|
||||||
|
"region_id": location.value[0],
|
||||||
|
"department_id": location.value[2]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case City():
|
||||||
|
payload["filters"]["location"]["locations"].append(
|
||||||
|
{
|
||||||
|
"area": {
|
||||||
|
"lat": location.lat,
|
||||||
|
"lng": location.lng,
|
||||||
|
"radius": location.radius
|
||||||
|
},
|
||||||
|
"city": location.city,
|
||||||
|
"label": f"{location.city} (toute la ville)" if location.city else None,
|
||||||
|
"locationType": "city"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case _:
|
||||||
|
raise InvalidValue("The provided location is invalid. It must be an instance of Region, Department, or City.")
|
||||||
|
|
||||||
|
# Search in title only
|
||||||
|
if text:
|
||||||
|
if search_in_title_only:
|
||||||
|
payload["filters"]["keywords"]["type"] = "subject"
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if not isinstance(value, (list, tuple)):
|
||||||
|
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:
|
||||||
|
raise InvalidValue(f"The value of '{key}' must be a list or tuple with at least two elements.")
|
||||||
|
|
||||||
|
if not "ranges" in payload["filters"]:
|
||||||
|
payload["filters"]["ranges"] = {}
|
||||||
|
|
||||||
|
payload["filters"]["ranges"][key] = {
|
||||||
|
"min": value[0],
|
||||||
|
"max": value[1]
|
||||||
|
}
|
||||||
|
# Enum
|
||||||
|
elif all(isinstance(x, str) for x in value):
|
||||||
|
payload["filters"]["enums"]["key"] = value
|
||||||
|
else:
|
||||||
|
raise InvalidValue(f"The value of '{key}' must be a list or tuple containing only integers or only strings.")
|
||||||
|
|
||||||
|
return payload
|
||||||
33
tests/test_search.py
Normal file
33
tests/test_search.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import lbc
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
client = lbc.Client()
|
||||||
|
|
||||||
|
# Paris
|
||||||
|
location = lbc.City(
|
||||||
|
lat=48.85994982004764,
|
||||||
|
lng=2.33801967847424,
|
||||||
|
radius=10_000, # 10 km
|
||||||
|
city="Paris"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.search(
|
||||||
|
text="maison",
|
||||||
|
locations=[location],
|
||||||
|
page=1,
|
||||||
|
limit=35,
|
||||||
|
limit_alu=0,
|
||||||
|
sort=lbc.Sort.NEWEST,
|
||||||
|
ad_type=lbc.AdType.OFFER,
|
||||||
|
category=lbc.Category.IMMOBILIER,
|
||||||
|
owner_type=lbc.OwnerType.ALL,
|
||||||
|
search_in_title_only=True,
|
||||||
|
square=[200, 400],
|
||||||
|
price=[300_000, 700_000],
|
||||||
|
)
|
||||||
|
|
||||||
|
for ad in result.ads:
|
||||||
|
print(ad.id, ad.url, ad.subject, ad.price)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
24
utils/generate_enum_categories.py
Normal file
24
utils/generate_enum_categories.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import lbc
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
def transform_str(string: str) -> str:
|
||||||
|
return string.strip().replace(" ", "_").replace("-", "_").replace("&", "et").upper().replace("É", "E").replace("È", "E").replace("Ê", "E").replace("Ë", "E").replace("À", "A").replace("Á", "A").replace("Ô", "O").replace(",", "").replace("___", "_").replace("'", "")
|
||||||
|
|
||||||
|
def print_category(category_data: dict, category_name: Optional[str] = None) -> None:
|
||||||
|
label: str = category_data["label"]
|
||||||
|
category_name: str = transform_str(category_name) if category_name else None
|
||||||
|
label = transform_str(label)
|
||||||
|
print(f'{f"{category_name}_" if category_name else ""}{label} = "{category_data['catId']}"')
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
client = lbc.Client()
|
||||||
|
body = client._fetch(method="GET", url="https://api.leboncoin.fr/api/frontend/v1/data/v7/fdata")
|
||||||
|
|
||||||
|
for category in body["categories"]:
|
||||||
|
print_category(category)
|
||||||
|
if category.get("subcategories", None):
|
||||||
|
for sub_category in category["subcategories"]:
|
||||||
|
print_category(sub_category, category_name=category["label"])
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
21
utils/generate_enum_departments.py
Normal file
21
utils/generate_enum_departments.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import lbc
|
||||||
|
|
||||||
|
def transform_str(string: str) -> str:
|
||||||
|
return string.strip().replace(" ", "_").replace("-", "_").replace("&", "et").upper().replace("É", "E").replace("È", "E").replace("Ê", "E").replace("Ë", "E").replace("À", "A").replace("Á", "A").replace("Ô", "O").replace(",", "").replace("___", "_").replace("'", "")
|
||||||
|
|
||||||
|
def print_department(department_data: dict, region: dict) -> None:
|
||||||
|
name: str = department_data["name"]
|
||||||
|
name = transform_str(name)
|
||||||
|
print(f'{name} = ("{region['rId']}", "{transform_str(region['rName'])}", "{department_data["dId"]}", "{name}")')
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
client = lbc.Client()
|
||||||
|
body = client._fetch(method="GET", url="https://api.leboncoin.fr/api/frontend/v1/data/v7/fdata")
|
||||||
|
|
||||||
|
for region in body["regions"]:
|
||||||
|
if region.get("departments", None):
|
||||||
|
for department in region["departments"]:
|
||||||
|
print_department(department, region=region)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
19
utils/generate_enum_regions.py
Normal file
19
utils/generate_enum_regions.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import lbc
|
||||||
|
|
||||||
|
def transform_str(string: str) -> str:
|
||||||
|
return string.strip().replace(" ", "_").replace("-", "_").replace("&", "et").upper().replace("É", "E").replace("È", "E").replace("Ê", "E").replace("Ë", "E").replace("À", "A").replace("Á", "A").replace("Ô", "O").replace(",", "").replace("___", "_").replace("'", "")
|
||||||
|
|
||||||
|
def print_region(region_data: dict) -> None:
|
||||||
|
name: str = region_data["rName"]
|
||||||
|
name = transform_str(name)
|
||||||
|
print(f'{name} = ("{region_data['rId']}", "{name}")')
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
client = lbc.Client()
|
||||||
|
body = client._fetch(method="GET", url="https://api.leboncoin.fr/api/frontend/v1/data/v7/fdata")
|
||||||
|
|
||||||
|
for region in body["regions"]:
|
||||||
|
print_region(region)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user