commit dfbbcbdbbb00ee83f93aa43c0fc1ad2e2c6df505 Author: etienne-hd Date: Thu Aug 21 23:50:34 2025 +0200 Initial release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e03682 --- /dev/null +++ b/.gitignore @@ -0,0 +1,211 @@ +# lbc-finder +id.json +logs/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$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 +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# 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 +.envrc +.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 entire 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 + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ 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..04d68bb --- /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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a387bc --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# lbc-finder +[![GitHub license](https://img.shields.io/github/license/etienne-hd/lbc?style=for-the-badge)](https://github.com/etienne-hd/lbc/blob/master/LICENSE) + +**Stay notified when new ads appear on Leboncoin** + +```python +from models import Search, Parameters +import lbc + +def handle(ad: lbc.Ad, search_name: str): + print(f"[{search_name}] New ads!") + print(f"Title : {ad.subject}") + print(f"Price : {ad.price} €") + print(f"URL : {ad.url}") + print("-" * 40) + +location = lbc.City( + lat=48.85994982004764, + lng=2.33801967847424, + radius=10_000, # 10 km + city="Paris" +) + +CONFIG = [ + Search( + name="Location Paris", + parameters=Parameters( + text="maison", + locations=[location], + category=lbc.Category.IMMOBILIER, + square=[200, 400], + price=[300_000, 700_000] + ), + delay=60 * 5, # Check every 5 minutes + handler=handle + ), + ... # More +] +``` +*lbc-finder is not affiliated with, endorsed by, or in any way associated with Leboncoin or its services. Use at your own risk.* + +This project uses [lbc](https://github.com/etienne-hd/lbc), an unofficial library to interact with Leboncoin API. + +## Features +* Advanced Search (text, category, price, location, square, etc.) +* Proxy Support for anonymity and bypassing rate limits +* Custom Logger with log file +* Configurable search interval (delay) +* Handler function triggered on new ads for full customization +* Multiple simultaneous searches with threading +* Easy integration with notifications (Discord, Telegram, email…) via handler + +## Installation +Docker support will be added soon. + +Required **Python 3.9+** +1. **Clone the repository** + ```bash + git clone https://github.com/etienne-hd/lbc-finder.git + cd lbc-finder + ``` +2. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +## Configuration +A [config.py](config.py) file is provided by default in the project, it contains a basic configuration. + +Inside this file, you must define a `CONFIG` variable, which is an list of `Search` objects. + +Each `Search` object should be configured with the rules for the ads you want to track. + +For example, if you want to track ads for a **Porsche 944** priced between 0€ and 25,000€ anywhere in France: +```python +from models import Search, Parameters + +Search( + name="Porsche 944", + parameters=Parameters( + text="Porsche 944", + category=lbc.Category.VEHICULES_VOITURES, + price=[0, 25_000] + ), + delay=60 * 5, # Every 5 minutes + handler=handle, + proxy=None +) +``` +### Name +A descriptive label for the Search. + +It has no impact on the actual query, it’s only used to identify the search. + +### Parameters + +All available parameters are documented in the [lbc](https://github.com/etienne-hd/lbc) repository. + +### Delay + +The time interval between each search. + +### Handler + +This function is called whenever a new ad appears. +It must accept two parameters: + +* the `Ad` object +* the name (label) of the search (e.g. **"Porsche 944"**) + +```python +def handle(ad: lbc.Ad, search_name: str) -> None: + ... +``` +You can find example handlers in the [examples](examples/) folder. + +### Proxy + +You can configure a proxy, here is an example: + +```python +from lbc import Proxy +from models import Search + +proxy = Proxy( + host="127.0.0.1", + port=9444, + username="etienne", + password="123456" +) + +Search( + name=..., + parameters=..., + delay=..., + handler=..., + proxy=proxy +) +``` + +## Usage +To run **lbc-finder**, simply start the `main.py` file: +```bash +python main.py +``` + +## 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/config.py b/config.py new file mode 100644 index 0000000..7049318 --- /dev/null +++ b/config.py @@ -0,0 +1,31 @@ +from models import Search, Parameters +import lbc + +def handle(ad: lbc.Ad, search_name: str): + print(f"[{search_name}] New ads!") + print(f"Title : {ad.subject}") + print(f"Price : {ad.price} €") + print(f"URL : {ad.url}") + print("-" * 40) + +location = lbc.City( + lat=48.85994982004764, + lng=2.33801967847424, + radius=10_000, # 10 km + city="Paris" +) + +CONFIG = [ + Search( + name="Location Paris", + parameters=Parameters( + text="maison", + locations=[location], + category=lbc.Category.IMMOBILIER, + square=[200, 400], + price=[300_000, 700_000] + ), + delay=60 * 5, # Check every 5 minutes + handler=handle + ), +] \ No newline at end of file diff --git a/examples/discord.py b/examples/discord.py new file mode 100644 index 0000000..26eb2dc --- /dev/null +++ b/examples/discord.py @@ -0,0 +1,49 @@ +import lbc +import requests +from datetime import datetime +from typing import Final + +WEBHOOK_URL: Final[str] = ... + +def handle(ad: lbc.Ad, search_name: str) -> None: + timestamp = datetime.strptime(ad.index_date, "%Y-%m-%d %H:%M:%S").timestamp() + + payload = { + "content": None, + "embeds": [ + { + "title": ad.title, + "description": f"```{ad.body}```", + "url": ad.url, + "color": 14381568, + "author": { + "name": ad.user.name, + "icon_url": ad.user.profile_picture + }, + "image": { + "url": ad.images[0] if ad.images else None + }, + "fields": [ + { + "name": "🕒 Publication", + "value": f"", + "inline": True + }, + { + "name": "💰 Price", + "value": f"`{ad.price}€`", + "inline": True + }, + { + "name": "📍 Location", + "value": f"`{ad.location.city_label}`", + "inline": True + } + ], + } + ], + "username": search_name, + "attachments": [] + } + + requests.post(WEBHOOK_URL, json=payload) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..0c239c0 --- /dev/null +++ b/main.py @@ -0,0 +1,9 @@ +from searcher import Searcher +from config import CONFIG + +def main() -> None: + searcher = Searcher(searches=CONFIG) + searcher.start() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..14b8f9a --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +from .search import Search +from .parameters import Parameters \ No newline at end of file diff --git a/models/parameters.py b/models/parameters.py new file mode 100644 index 0000000..2e1d40e --- /dev/null +++ b/models/parameters.py @@ -0,0 +1,24 @@ +from typing import Optional, Union, List +from lbc import Category, Region, Department, City, OwnerType + +from typing import overload + +class Parameters: + @overload + def __init__( + self, + url: Optional[str] = None, + text: Optional[str] = None, + category: Category = Category.TOUTES_CATEGORIES, + locations: Optional[Union[List[Union[Region, Department, City]], Union[Region, Department, City]]] = None, + limit: int = 35, + limit_alu: int = 3, + page: int = 1, + owner_type: Optional[OwnerType] = None, + shippable: Optional[bool] = None, + search_in_title_only: bool = False, + **kwargs + ): ... + + def __init__(self, **kwargs): + self._kwargs = kwargs \ No newline at end of file diff --git a/models/search.py b/models/search.py new file mode 100644 index 0000000..dfba60f --- /dev/null +++ b/models/search.py @@ -0,0 +1,12 @@ +from lbc import Proxy, Ad +from .parameters import Parameters +from dataclasses import dataclass +from typing import Callable + +@dataclass +class Search: + name: str + parameters: Parameters + delay: float + handler: Callable[[Ad, str], None] + proxy: Proxy = None \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..104db0b --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +lbc==1.0.9 diff --git a/searcher/__init__.py b/searcher/__init__.py new file mode 100644 index 0000000..f9f57b2 --- /dev/null +++ b/searcher/__init__.py @@ -0,0 +1,2 @@ +from .searcher import Searcher +from .logger import logger \ No newline at end of file diff --git a/searcher/id.py b/searcher/id.py new file mode 100644 index 0000000..a90e4a0 --- /dev/null +++ b/searcher/id.py @@ -0,0 +1,36 @@ +from .logger import logger + +from typing import List, Final +import os +import json + +MAX_ID: Final[int] = 10_000 + +class ID: + def __init__(self): + self._ids: List[str] = self._get_ids() + + @property + def ids(self) -> List[str]: + return self._ids + + def _get_ids(self) -> List[str]: + ids: List[str] = [] + if os.path.exists("id.json"): + with open("id.json", "r") as f: + try: + ids = json.load(f) + except json.JSONDecodeError: + os.remove("id.json") + except: + logger.exception("An error occurred while attempting to open the id.json file.") + return ids + + def add(self, id: str) -> bool: + if not id in self._ids: + self._ids.append(id) + with open("id.json", "w") as f: + json.dump(self._ids[-MAX_ID:], f, indent=3) + self._ids = self._ids[-MAX_ID:] + return True + return False \ No newline at end of file diff --git a/searcher/logger.py b/searcher/logger.py new file mode 100644 index 0000000..9def3bb --- /dev/null +++ b/searcher/logger.py @@ -0,0 +1,23 @@ +import logging +import os +from datetime import datetime + +# File management +timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") +file_path: str = os.path.join("logs", f"log_{timestamp}.log") +os.makedirs("logs", exist_ok=True) + +# Config logging +logger = logging.getLogger("lbc-finder") + +formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] [%(threadName)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') +stream_handler = logging.StreamHandler() +stream_handler.setFormatter(formatter) +logger.addHandler(stream_handler) +logger.setLevel(logging.INFO) + +# Log File +file_handler = logging.FileHandler(file_path, mode='w', encoding='utf-8') +file_handler.setLevel(logging.WARNING) +file_handler.setFormatter(formatter) +logger.addHandler(file_handler) \ No newline at end of file diff --git a/searcher/searcher.py b/searcher/searcher.py new file mode 100644 index 0000000..97a08c7 --- /dev/null +++ b/searcher/searcher.py @@ -0,0 +1,40 @@ +from models import Search +from lbc import Client, Sort +from .id import ID +from .logger import logger + +import time +import threading +from typing import List, Union + +class Searcher: + def __init__(self, searches: Union[List[Search], Search], request_verify: bool = True): + self._searches: List[Search] = searches if isinstance(searches, list) else [searches] + self._request_verify = request_verify + self._id = ID() + + def _search(self, search: Search) -> None: + client = Client(proxy=search.proxy, request_verify=self._request_verify) + while True: + before = time.time() + try: + response = client.search(**search.parameters._kwargs, sort=Sort.NEWEST) + logger.debug(f"Successfully found {response.total} ad{'s' if response.total > 1 else ''}.") + ads = [ad for ad in response.ads if self._id.add(ad.id)] + if len(ads): + logger.info(f"Successfully found {len(ads)} new ad{'s' if len(ads) > 1 else ''}!") + for ad in ads: + search.handler(ad, search.name) + except: + logger.exception(f"An error occured.") + time.sleep(search.delay - (time.time() - before) if search.delay - (time.time() - before) > 0 else 0) + + def start(self) -> bool: + if not len(self._searches): + logger.warning("No search rules have been set. Please create search rules in config.py (see example in README.md).") + return False + + for search in self._searches: + threading.Thread(target=self._search, args=(search,), name=search.name).start() + time.sleep(5) # Add latency between each thread to prevent spam + return True \ No newline at end of file