commit 7de8d5bab2e7fa9b19ab45b40415aa4e4043fa29 Author: etienne-hd Date: Fri Jun 20 00:59:56 2025 +0200 Initial release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b004e5 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..33259b8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +## v1.0.0 +* Initial release \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..55f74d8 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9e1809 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# lbc +[![Latest version](https://img.shields.io/pypi/v/lbc?style=for-the-badge)](https://pypi.org/project/lbc) +![PyPI - Downloads](https://img.shields.io/pypi/dm/lbc?style=for-the-badge) +[![GitHub license](https://img.shields.io/github/license/etienne-hd/lbc?style=for-the-badge)](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 + +Buy Me A Coffee + +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. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9a174ee --- /dev/null +++ b/pyproject.toml @@ -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" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aa199c0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +curl_cffi==0.11.3 \ No newline at end of file diff --git a/src/lbc/__init__.py b/src/lbc/__init__.py new file mode 100644 index 0000000..44ffa53 --- /dev/null +++ b/src/lbc/__init__.py @@ -0,0 +1,2 @@ +from .client import Client +from .models import * \ No newline at end of file diff --git a/src/lbc/client.py b/src/lbc/client.py new file mode 100644 index 0000000..47b8371 --- /dev/null +++ b/src/lbc/client.py @@ -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) diff --git a/src/lbc/exceptions.py b/src/lbc/exceptions.py new file mode 100644 index 0000000..4f54a0e --- /dev/null +++ b/src/lbc/exceptions.py @@ -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.""" diff --git a/src/lbc/models/__init__.py b/src/lbc/models/__init__.py new file mode 100644 index 0000000..1a7c89b --- /dev/null +++ b/src/lbc/models/__init__.py @@ -0,0 +1,4 @@ +from .proxy import Proxy +from .search import Search +from .enums import * +from .city import City \ No newline at end of file diff --git a/src/lbc/models/ad.py b/src/lbc/models/ad.py new file mode 100644 index 0000000..66fd5a4 --- /dev/null +++ b/src/lbc/models/ad.py @@ -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 \ No newline at end of file diff --git a/src/lbc/models/attribute.py b/src/lbc/models/attribute.py new file mode 100644 index 0000000..56126ed --- /dev/null +++ b/src/lbc/models/attribute.py @@ -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 \ No newline at end of file diff --git a/src/lbc/models/city.py b/src/lbc/models/city.py new file mode 100644 index 0000000..9ab012f --- /dev/null +++ b/src/lbc/models/city.py @@ -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 \ No newline at end of file diff --git a/src/lbc/models/enums.py b/src/lbc/models/enums.py new file mode 100644 index 0000000..27de24e --- /dev/null +++ b/src/lbc/models/enums.py @@ -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" \ No newline at end of file diff --git a/src/lbc/models/location.py b/src/lbc/models/location.py new file mode 100644 index 0000000..257f655 --- /dev/null +++ b/src/lbc/models/location.py @@ -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 \ No newline at end of file diff --git a/src/lbc/models/owner.py b/src/lbc/models/owner.py new file mode 100644 index 0000000..b537f11 --- /dev/null +++ b/src/lbc/models/owner.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +@dataclass +class Owner: + store_id: str + user_id: str + type: str + name: str + no_salesmen: bool \ No newline at end of file diff --git a/src/lbc/models/proxy.py b/src/lbc/models/proxy.py new file mode 100644 index 0000000..592f7c4 --- /dev/null +++ b/src/lbc/models/proxy.py @@ -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}" \ No newline at end of file diff --git a/src/lbc/models/search.py b/src/lbc/models/search.py new file mode 100644 index 0000000..7803caf --- /dev/null +++ b/src/lbc/models/search.py @@ -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 + ) \ No newline at end of file diff --git a/src/lbc/session.py b/src/lbc/session.py new file mode 100644 index 0000000..aaf317e --- /dev/null +++ b/src/lbc/session.py @@ -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 \ No newline at end of file diff --git a/src/lbc/utils.py b/src/lbc/utils.py new file mode 100644 index 0000000..72eda4f --- /dev/null +++ b/src/lbc/utils.py @@ -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 \ No newline at end of file diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..12c699c --- /dev/null +++ b/tests/test_search.py @@ -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() \ No newline at end of file diff --git a/utils/generate_enum_categories.py b/utils/generate_enum_categories.py new file mode 100644 index 0000000..5eec2ee --- /dev/null +++ b/utils/generate_enum_categories.py @@ -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() \ No newline at end of file diff --git a/utils/generate_enum_departments.py b/utils/generate_enum_departments.py new file mode 100644 index 0000000..aa3ad5e --- /dev/null +++ b/utils/generate_enum_departments.py @@ -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() \ No newline at end of file diff --git a/utils/generate_enum_regions.py b/utils/generate_enum_regions.py new file mode 100644 index 0000000..b976edb --- /dev/null +++ b/utils/generate_enum_regions.py @@ -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() \ No newline at end of file