mirror of
https://github.com/etienne-hd/lbc.git
synced 2025-12-05 17:18:18 +01: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