mirror of
https://github.com/etienne-hd/lbc-finder.git
synced 2025-12-05 09:08:10 +01:00
Initial release
This commit is contained in:
211
.gitignore
vendored
Normal file
211
.gitignore
vendored
Normal file
@@ -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__/
|
||||
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.
|
||||
155
README.md
Normal file
155
README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# lbc-finder
|
||||
[](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
|
||||
|
||||
<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.
|
||||
31
config.py
Normal file
31
config.py
Normal file
@@ -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
|
||||
),
|
||||
]
|
||||
49
examples/discord.py
Normal file
49
examples/discord.py
Normal file
@@ -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"<t:{int(timestamp)}:R>",
|
||||
"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)
|
||||
9
main.py
Normal file
9
main.py
Normal file
@@ -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()
|
||||
2
models/__init__.py
Normal file
2
models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .search import Search
|
||||
from .parameters import Parameters
|
||||
24
models/parameters.py
Normal file
24
models/parameters.py
Normal file
@@ -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
|
||||
12
models/search.py
Normal file
12
models/search.py
Normal file
@@ -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
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
lbc==1.0.9
|
||||
2
searcher/__init__.py
Normal file
2
searcher/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .searcher import Searcher
|
||||
from .logger import logger
|
||||
36
searcher/id.py
Normal file
36
searcher/id.py
Normal file
@@ -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
|
||||
23
searcher/logger.py
Normal file
23
searcher/logger.py
Normal file
@@ -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)
|
||||
40
searcher/searcher.py
Normal file
40
searcher/searcher.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user