41 Commits
v2.4 ... v3.3

Author SHA1 Message Date
1b15946a55 Merge pull request 'Better default env var' (#19) from wip into main
Reviewed-on: #19
2025-07-12 13:20:50 +02:00
af86d4aa8f Better default env var 2025-07-12 11:19:55 +00:00
f2b5b184ea Merge pull request 'Modular python files' (#18) from wip into main
Reviewed-on: #18
2025-07-11 13:51:23 +02:00
50a9c1f827 Merge pull request 'Modular python files' (#16) from wip-modules into wip
Reviewed-on: #16
2025-07-11 13:47:23 +02:00
584c222076 Revamp 2025-07-11 11:42:52 +00:00
70a7df1f97 Modules 2025-07-11 10:24:38 +00:00
70b723b781 Merge pull request 'v3.2.1 Hotfix - Fixed Discord role issues on SKU change + language fallback strategy' (#14) from wip into main
Reviewed-on: #14
2025-07-10 10:08:28 +02:00
62ef15c928 Fixed Discord role issues on SKU change + language fallback strategy 2025-07-10 08:06:27 +00:00
fe3e8f9609 Fixed README and docker-compose variables 2025-07-10 07:30:21 +00:00
a95d1d41d5 Merge pull request 'wip' (#13) from wip into main
Reviewed-on: #13
2025-07-10 00:01:22 +02:00
b2789c8942 Fixed Readme 2025-07-09 22:00:06 +00:00
998c7dd500 Fixed Readme 2025-07-09 21:59:01 +00:00
4699f686a2 Localization (EU languages) 2025-07-09 21:55:00 +00:00
9bb8e8d3d7 Merge pull request 'Python 3.11 - Alpine + loop improvement' (#11) from wip into main
Reviewed-on: #11
2025-07-09 18:14:14 +02:00
66c4146223 Python 3.11 - Alpine + loop improvement 2025-07-09 16:13:12 +00:00
92edccd8d8 Merge pull request 'Multi GPU merge' (#10) from wip into main
Reviewed-on: #10
2025-07-08 19:15:29 +02:00
a2561a79b8 Fixed README 2025-07-08 16:58:26 +00:00
d4735e32b3 Fixed README 2025-07-08 16:57:49 +00:00
28991dd7a7 Fixed summary 2025-07-08 16:34:49 +00:00
781fb1270a Fixed summary 2025-07-08 16:32:29 +00:00
0eb569f7ec Updated README 2025-07-08 16:28:14 +00:00
be263e8cd1 One role per GPU support 2025-07-08 15:54:56 +00:00
6c4be66f3d Multi GPU support 2025-07-08 14:34:55 +00:00
7ca0483cfd Fixed readme 2025-07-08 09:34:06 +00:00
c8dcafbe01 v2.6 - new compose + new python execution process 2025-07-08 09:29:22 +00:00
1f9a237fce Added role env var 2025-07-08 09:15:56 +00:00
7280146d5e Removed french discord link 2025-07-07 14:12:58 +00:00
f86c56b7d1 Merge branch 'wip' 2025-07-06 13:12:35 +00:00
afbc79bee4 Github warn 2025-07-06 13:08:20 +00:00
9c2825736e Merge pull request 'wip - In english please' (#9) from wip into main
Reviewed-on: #9
2025-06-03 15:21:36 +02:00
bfd8428073 Fixed logo 2025-06-03 13:19:02 +00:00
b0eef8b8df Logo translated 2025-06-03 13:17:10 +00:00
e2d86cb787 Fully translated 2025-06-03 12:58:34 +00:00
8124ad6a2d In english please 2025-06-03 12:41:49 +00:00
27966c04e5 Merge pull request 'Correction structure, orthographe, emoji' (#8) from wip into main
Reviewed-on: #8
2025-04-24 12:53:48 +02:00
17ade38bcd Correction structure, orthographe, emoji 2025-04-24 10:51:52 +00:00
2f8ae16533 Merge pull request 'Correction variables par défaut' (#7) from wip into main
Reviewed-on: #7
2025-04-23 21:12:32 +02:00
80fe61f91d Minor changes 2025-04-23 18:58:15 +00:00
f07ff328e5 Merge pull request 'wip' (#6) from wip into main
Reviewed-on: #6
2025-04-23 19:00:54 +02:00
6657c03b07 Minor changes 2025-04-23 16:59:04 +00:00
9dae286965 Mise à jour de la documentation 2025-04-23 16:57:35 +00:00
14 changed files with 779 additions and 451 deletions

View File

@ -1,22 +1,11 @@
# Utiliser une image de base légère de Python (ici Python 3.9)
FROM python:3.9-slim
FROM python:3.11-alpine
RUN apk add --no-cache ca-certificates
# Définir le répertoire de travail à l'intérieur du conteneur
WORKDIR /app
# Copier le script Python dans le répertoire de travail
COPY nvidia-stock-bot.py /app/
COPY /app/ /app/
# Copier un éventuel fichier requirements.txt pour installer des dépendances
# Si des dépendances supplémentaires sont nécessaires, ajoutez-les dans requirements.txt
COPY requirements.txt /app/
# Installer les dépendances Python à partir de requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Définir les variables d'environnement par défaut (modifiable lors du lancement du conteneur)
ENV DISCORD_WEBHOOK_URL="https://example.com/webhook" \
REFRESH_TIME="60"
# Exposer un point de commande pour exécuter le script
CMD ["python", "nvidia-stock-bot.py"]
CMD ["python", "main.py"]

159
README.md
View File

@ -1,165 +1,188 @@
<h1 align="center">Nvidia Stock Bot</h1>
<div align="center">
<a href="https://discord.gg/gxffg3GA96">
<img src="https://img.shields.io/badge/JV%20hardware-rejoindre-green?style=flat-square&logo=discord&logoColor=%23fff" alt="JV Hardware">
<a href="https://creativecommons.org/licenses/by-nc/4.0/" target="_blank">
<img src="https://img.shields.io/badge/License-CC%20BY--NC%204.0-8E44AD?style=flat-square" alt="License: CC BY-NC 4.0">
</a>
</div>
<div align="center" >
<img src="https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/nvidia-stock-bot-logo.png" alt="Nvidia Stock Bot" width="300">
</div>
**Nvidia Stock Bot** - Un robot qui permet d'être alerté en temps réel des stocks de cartes graphiques **Nvidia RTX FE** grâce à des notifications Discord.
**🤖 Nvidia Stock Bot** - A bot that alerts you in real-time about **Nvidia RTX FE** GPU stock availability through Discord notifications.
*Le code a été en partie rédigé et structuré à l'aide d'une IA générative.*
> [!NOTE]
>_The code was partially written and structured using a generative AI._
>
>_Github repo is a mirror of https://git.djeex.fr/Djeex/nvidia-stock-bot. You'll find full package, history and release note there._
## Sommaire
## 📌 Table of Contents
- [Fonctionnalités](#fonctionnalit%C3%A9s)
- [Installation docker sans le dépot (rapide)](#installation-sans-le-d%C3%A9pot-avec-docker-compose)
- [Installation docker avec le dépot (développeur)](#installation-avec-le-d%C3%A9pot)
- [Installation avec Python (développeur)](#installation-avec-python)
- [Captures d'écran](#captures-d%C3%A9cran)
- [Contributeurs](#contributeurs)
- [✨ Features](#-features)
- [🐳 Docker Installation without cloning the repo (quick)](#-docker-installation-without-the-repo-quick)
- [🐙 Docker Installation with the repo (developer)](#-docker-installation-with-the-repo)
- [🐍 Python Installation (developer)](#-python-installation)
- [🖼️ Screenshots](#-screenshots)
- [🐞 Common issues](#-common-issues)
- [🧑‍💻 Contributors](#-contributors)
## Fonctionnalités
## ✨ Features
- Notification Discord `@everyone` en cas de changement du SKU (potentiel drop imminent)
- Notification Discord `@everyone` en cas de stock détecté avec modèle, prix, et lien
- Notification Discord silencieuse en cas d'absence de stock détécté
- Choix de la fréquence de la vérification
- Choix du modèle à surveiller
- Selectable GPU models
- Discord `@everyone` or specified role on SKU change (possible imminent drop)
- Discord `@everyone` or specified role notification when stock is detected, including model, price, and link
- Silent Discord notification when no stock is detected
- Selectable notification language
- Selectable notification server name in footer
- Selectable check frequency
<img src="https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/nvbot_schematics.png" align="center">
## 🐳 Docker Installation without the repo (quick)
## Installation sans le dépot avec docker compose
Below are the instructions to set up the container using our pre-built image. With this setup, your bot will run independently as long as the container is active.
Vous trouverez-ci dessous les instructions pour configurer le conteneur avec notre image pré-compilée. Avec cette solution, votre bot tournera tout seul tant que le conteneur est actif.
**Pré-requis**
**Requirements**
- [Docker](https://docs.docker.com/engine/install/)
**Configuration**
- Créez un dossier `nvidia-stock-bot`
- Créez le fichier `compose.yaml` dans ce dossier avec la configuration ci-dessous :
- Create a folder named `nvidia-stock-bot`
- Create a `compose.yaml` file inside that folder with the following content:
```yaml
version: "3.8"
services:
nvidia-stock-bot:
image: git.djeex.fr/djeex/nvidia-stock-bot:latest
container_name: nvidia-stock-bot
restart: always
restart: unless-stopped
environment:
- DISCORD_WEBHOOK_URL= # URL de votre webhook Discord
- REFRESH_TIME= # Durée de rafraichissement du script en secondes
- API_URL_SKU= # API listant le produit par exemple https://api.nvidia.partners/edge/product/search?page=1&limit=100&locale=fr-fr&Manufacturer=Nvidia&gpu=RTX%205090
- API_URL_STOCK= # API appelant le stock sans préciser la valeur du sku, par exemple https://api.store.nvidia.com/partner/v1/feinventory?locale=fr-fr&skus=
- PRODUCT_URL= # URL d'achat du GPU
- PRODUCT_NAME= # Le nom du GPU qui s'affiche dans les notifications
- TEST_MODE= # true pour tester les notifications discord. false par défaut.
- PYTHONUNBUFFERED=1 # Permet d'afficher les logs en temps réel
command: python nvidia-stock-bot.py # Lance le script Python au démarrage du conteneur
# Minimal environment variables
- PRODUCT_NAMES= # Exact GPU name (e.g. "RTX 5080, RTX 5090")
- DISCORD_WEBHOOK_URL= # Your Discord webhook URL
- API_URL_SKU= # API listing the product for your country
- API_URL_STOCK= # API providing stock data for your country
- PRODUCT_URL= # GPU purchase URL for your country
- PYTHONUNBUFFERED=1 # Enables real-time log output
```
**Lancer l'image**
**Environment Variables:**
Rendez-vous dans le dossier `nvidia-stock-bot` et lancez le conteneur :
| Variable | Description | Possible Values | Default Value |
|---------------------|-------------------------------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| `PRODUCT_NAMES` | The exact GPU names you're searching for | `RTX 5080, RTX 5090` | |
| `DISCORD_WEBHOOK_URL` | Your Discord webhook URL | A valid URL | |
| `DISCORD_SERVER_NAME` | The name of your server, displayed in notification's footer | A text | Shared for free |
| `DISCORD_NOTIFICATION_LANGUAGE` | Your language for notification's content | `bg`, `cs`, `da`, `de`, `el`, `en`, `es`, `et`, `fi`, `fr`, `ga`, `hr`, `hu`, `it`, `lt`, `lv`, `mt`, `nl`, `pl`, `pt`, `ro`, `sk`, `sl`, `sv` | `en` |
| `DISCORD_ROLES` | List of Discord roles ID in the same order than `PRODUCT_NAMES` values, found in your discord server settings (with user profile developer mode enabled) | `<@&12345><@&6789>` | @everyone |
| `REFRESH_TIME` | Script refresh interval in seconds | `60`, `30`, etc. | `30` |
| `API_URL_SKU` | API listing the product | A URL. API url can change over time. For now, you can use the default one and change the `locale` parameter to yours (e.g. `locale=en-gb`) | `https://api.nvidia.partners/edge/product/search?page=1&limit=100&locale=fr-fr&Manufacturer=Nvidia` |
| `API_URL_STOCK` | API providing stock data | A URL. API url can change over time. For now, you can use the default one and change the `locale` parameter to yours (e.g. `locale=en-gb`) | `https://api.store.nvidia.com/partner/v1/feinventory?locale=fr-fr&skus=` |
| `PRODUCT_URL` | GPU purchase URL. There isn't any direct link workinf right now, so put the generic marketplace url listing all FE products | A URL. API url can change over time. For now, you can use the default one and change the locale parameter to yours (e.g. `/en-gb/`) | `https://marketplace.nvidia.com/fr-fr/consumer/graphics-cards/?locale=fr-fr&page=1&limit=12&manufacturer=NVIDIA` |
| `TEST_MODE` | For testing without sending notifications | `True`, `False` | `False` |
| `PYTHONUNBUFFERED` | Enables real-time log output | `1`, `0` | `1` |
**Run the image**
Navigate to the `nvidia-stock-bot` folder and launch the container:
```sh
docker compose up -d
```
**Voir les logs pour vérifier le bon fonctionnement**
**Check logs to verify operation**
```sh
docker logs -f nvidia-stock-bot
```
## Installation avec le dépot
## 📦 Docker Installation with the repo
Vous trouverez-ci dessous les instructions pour installer le dépot, compiler l'image docker, et lancer le conteneur. Avec cette solution, votre bot tournera tout seul tant que le conteneur est actif.
Instructions below show how to install the repo, build the Docker image, and launch the container. Your bot will run independently as long as the container is active.
**Pré-requis**
**Requirements**
- [Git](https://git-scm.com/docs)
- [Docker](https://docs.docker.com/engine/install/)
**Cloner et paramétrer**
**Clone and configure**
- Clonez le repo :
- Clone the repo:
```sh
git clone https://git.djeex.fr/Djeex/nvidia-stock-bot.git
```
- Rendez vous dans le dossier `nvidia-stock-bot` et compilez l'image docker :
- Navigate to `nvidia-stock-bot` and build the Docker image:
```sh
docker build -t nvidia-stock-bot .
```
- Puis rendez-vous dans le dossier `nvidia-stock-bot/docker` et éditez le fichier `.env` avec :
- l'url de votre webhook discord
- les différents liens API et produits
- la fréquence de consultation des stock (par défaut 60s, attention à ne pas trop descendre sous peine de blocage de votre adresse IP par nVidia)
- Then go to `nvidia-stock-bot/docker` and edit the `.env` file with:
- Your Discord webhook URL
- The API and product URLs
- Stock checking frequency (default: 60s; lowering too much may get your IP blocked by Nvidia)
**Lancer l'image**
**Run the image**
Rendez-vous dans le dossier `nvidia-stock-bot/docker` et lancez le conteneur :
Navigate to `nvidia-stock-bot/docker` and launch the container:
```sh
docker compose up -d
```
**Voir les logs pour vérifier le bon fonctionnement**
**Check logs to verify operation**
```sh
docker logs -f nvidia-stock-bot
```
## Installation avec Python
## 🐍 Python Installation
Vous trouverez ci-dessous comment exécuter directement le script Python. Avec cette solution, le bot s'arretera si vous fermez votre terminal.
Instructions to directly run the Python script. Note: the bot stops when you close the terminal.
**Pré-requis**
**Requirements**
- Python 3.11 ou plus
- Python 3.11 or newer
- requests: `pip install requests`
**Configuration**
- Créez un environnement virtuel (exemple : `python3 -m venv nom_de_l_environnement` )
- Créez un dossier et aller dedans
- Téléchargez le script python :
- Clone the repo:
```sh
curl -o nvidia-stock-bot.py -# https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/nvidia-stock-bot.py
git clone https://git.djeex.fr/Djeex/nvidia-stock-bot.git
```
- Exportez les variables d'environnement avec votre webhook discord et le temps de rafraichissement en secondes, par exemple :
- Navigate to `nvidia-stock-bot` and create a virtual environment (e.g., `python3 -m venv env_name`)
- Export the environment variables with your webhook and refresh time, for exemple:
```sh
export DISCORD_WEBHOOK_URL="https://votre_url_discord"
export DISCORD_WEBHOOK_URL="https://your_discord_url"
export PRODUCT_NAMES=RTX 5080, RTX 5090
export DISCORD_ROLES=<@&12345>, <@&6789>
export REFRESH_TIME="60"
export API_URL_SKU="https://api.nvidia.partners/edge/product/search?page=1&limit=100&locale=fr-fr&Manufacturer=Nvidia&gpu=RTX%205080"
export API_URL_STOCK="https://api.store.nvidia.com/partner/v1/feinventory?locale=fr-fr&skus="
export PRODUCT_URL="https://marketplace.nvidia.com/fr-fr/consumer/graphics-cards/?locale=fr-fr&page=1&limit=12&gpu=RTX%205080&manufacturer=NVIDIA"
export PRODUCT_NAME="RTX 5080"
export TEST_MODE=false
export PYTHONUNBUFFERED=1
```
- Lancez le script
- Run the script
```sh
python nvidia-stock-bot.py
```
## Captures d'écran
## 🖼️ Screenshots
<div align="center" >
<img src="https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/nvidia-stock-bot-discord.png" alt="Nvidia Stock Bot - captures">
<img src="https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/nvidia-stock-bot-discord.png" alt="Nvidia Stock Bot - screenshots">
</div>
## Contributeurs
## 🐞 Common issues
On remercie pour leurs contributions :
Error when trying to reach product API url :
- `API_SKU_URL` may be wrong
- Your IP may be blacklisted by nvidia. Try to use a VPN.
- nvidia API may be down
## 🧑‍💻 Contributors
Thanks for their contributions:
- Djeex
- KevOut

143
app/env_config.py Normal file
View File

@ -0,0 +1,143 @@
import os
import re
import logging
import json
import sys
# Logger setup
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logging.info("Script started")
try:
# Env variables
DISCORD_WEBHOOK_URL = os.environ['DISCORD_WEBHOOK_URL']
DISCORD_SERVER_NAME = os.environ.get('DISCORD_SERVER_NAME', 'Shared for free')
API_URL_SKU = os.environ.get('API_URL_SKU', 'https://api.nvidia.partners/edge/product/search?page=1&limit=100&locale=fr-fr&Manufacturer=Nvidia')
API_URL_STOCK = os.environ.get('API_URL_STOCK', 'https://api.store.nvidia.com/partner/v1/feinventory?locale=fr-fr&skus=')
REFRESH_TIME = int(os.environ.get('REFRESH_TIME', 30))
TEST_MODE = os.environ.get('TEST_MODE', 'False').lower() == 'true'
PRODUCT_URL = os.environ.get('PRODUCT_URL', 'https://marketplace.nvidia.com/fr-fr/consumer/graphics-cards/?locale=fr-fr&page=1&limit=12&manufacturer=NVIDIA')
DISCORD_ROLES = os.environ.get('DISCORD_ROLES')
PRODUCT_NAMES = os.environ['PRODUCT_NAMES']
except KeyError as e:
logging.error(f"Missing environment variable: {e}")
sys.exit(1)
except ValueError:
logging.error("REFRESH_TIME must be a valid integer.")
sys.exit(1)
if not PRODUCT_NAMES:
logging.error("❌ PRODUCT_NAMES is required but not defined.")
sys.exit(1)
PRODUCT_NAMES = [name.strip() for name in PRODUCT_NAMES.split(',')]
# Role mapping
DISCORD_ROLE_MAP = {}
if not DISCORD_ROLES or not DISCORD_ROLES.strip():
logging.warning("⚠️ DISCORD_ROLES not defined or empty. Defaulting all roles to @everyone.")
for name in PRODUCT_NAMES:
DISCORD_ROLE_MAP[name] = '@everyone'
else:
roles = [r.strip() if r.strip() else '@everyone' for r in DISCORD_ROLES.split(',')]
if len(roles) != len(PRODUCT_NAMES):
logging.error("❌ The number of DISCORD_ROLES must match PRODUCT_NAMES.")
sys.exit(1)
for name, role in zip(PRODUCT_NAMES, roles):
if role != '@everyone' and not re.match(r'^<@&\d{17,20}>$', role):
logging.error(f"❌ Invalid DISCORD_ROLE format for {name}: {role}")
sys.exit(1)
DISCORD_ROLE_MAP[name] = role
if not DISCORD_WEBHOOK_URL:
logging.error("❌ DISCORD_WEBHOOK_URL is required but not defined.")
sys.exit(1)
# Masked webhook for display
match = re.search(r'/(\d+)/(.*)', DISCORD_WEBHOOK_URL)
if match:
webhook_id = match.group(1)
webhook_token = match.group(2)
masked_webhook_id = webhook_id[:len(webhook_id) - 10] + '*' * 10
masked_webhook_token = webhook_token[:len(webhook_token) - 120] + '*' * 10
wh_masked_url = f"https://discord.com/api/webhooks/{masked_webhook_id}/{masked_webhook_token}"
else:
wh_masked_url = "[Invalid webhook URL]"
# Test mode
if TEST_MODE:
logging.warning("🚧 Test mode is active. No real alerts will be sent.")
# HTTP headers
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.5",
"Cache-Control": "max-age=0",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"Sec-GPC": "1",
}
# Localization loading
language = os.environ.get("DISCORD_NOTIFICATION_LANGUAGE", "en").lower()
try:
with open("localization.json", "r", encoding="utf-8") as f:
localization = json.load(f)
except FileNotFoundError:
logging.error("❌ localization.json file not found.")
sys.exit(1)
required_keys = [
"in_stock_title", "out_of_stock_title", "sku_change_title",
"buy_now", "price", "time", "footer",
"sku_description", "imminent_drop"
]
loc = localization.get(language, localization.get("en", {}))
logging.info(f"Notification language: {language}")
if not loc:
logging.warning(f"⚠️ Language '{language}' not found. Falling back to English.")
loc = localization.get("en", {})
language = "en"
missing_keys = [key for key in required_keys if key not in loc]
fallback = localization.get("en", {})
for key in missing_keys:
if key in fallback:
loc[key] = fallback[key]
else:
logging.error(f"❌ Missing required key '{key}' in both '{language}' and fallback 'en'.")
sys.exit(1)
# Public constants
in_stock_title = loc["in_stock_title"]
out_of_stock_title = loc["out_of_stock_title"]
sku_change_title = loc["sku_change_title"]
buy_now = loc["buy_now"]
price_label = loc["price"]
time_label = loc["time"]
footer = loc["footer"]
sku_description = loc["sku_description"]
imminent_drop = loc["imminent_drop"]
# Logging
logging.info(f"GPU: {PRODUCT_NAMES}")
logging.info(f"Discord Webhook URL: {wh_masked_url}")
logging.info(f"Discord Role Mention: {DISCORD_ROLES}")
logging.info(f"API URL SKU: {API_URL_SKU}")
logging.info(f"API URL Stock: {API_URL_STOCK}")
logging.info(f"Product URL: {PRODUCT_URL}")
logging.info(f"Refresh time: {REFRESH_TIME} seconds")
logging.info(f"Test Mode: {TEST_MODE}")

104
app/gpu_checker.py Normal file
View File

@ -0,0 +1,104 @@
import requests
import logging
from env_config import API_URL_SKU, API_URL_STOCK, HEADERS, PRODUCT_NAMES, PRODUCT_URL
from notifier import send_discord_notification, send_out_of_stock_notification, send_sku_change_notification
from requests.adapters import HTTPAdapter, Retry
session = requests.Session()
retries = Retry(total=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
session.mount('https://', HTTPAdapter(max_retries=retries))
last_sku_dict = {}
global_stock_status_dict = {}
first_run_dict = {name: True for name in PRODUCT_NAMES}
def check_rtx_50_founders():
global last_sku_dict, global_stock_status_dict, first_run_dict
try:
response = session.get(API_URL_SKU, headers=HEADERS, timeout=10)
logging.info(f"SKU API response: {response.status_code}")
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
logging.error(f"SKU API error: {e}")
return
# All available products
all_products = data['searchedProducts']['productDetails']
for product_name in PRODUCT_NAMES:
product_details = None
for p in all_products:
if p.get("gpu", "").strip() == product_name:
product_details = p
break
if not product_details:
logging.warning(f"⚠️ No product with GPU '{product_name}' found.")
continue
product_sku = product_details['productSKU']
product_upc = product_details.get('productUPC', "")
if not isinstance(product_upc, list):
product_upc = [product_upc]
# Check SKU change
old_sku = last_sku_dict.get(product_name)
if old_sku and old_sku != product_sku and not first_run_dict[product_name]:
logging.warning(f"⚠️ SKU changed for {product_name}: {old_sku}{product_sku}")
send_sku_change_notification(product_name, old_sku, product_sku, PRODUCT_URL)
last_sku_dict[product_name] = product_sku
first_run_dict[product_name] = False
# Stock check
api_stock_url = API_URL_STOCK + product_sku
logging.info(f"[{product_name}] Checking stock: {api_stock_url}")
try:
response = session.get(api_stock_url, headers=HEADERS, timeout=10)
logging.info(f"[{product_name}] Stock API response: {response.status_code}")
response.raise_for_status()
stock_data = response.json()
except requests.exceptions.RequestException as e:
logging.error(f"Stock API error: {e}")
continue
products = stock_data.get("listMap", [])
products_price = "Price not available"
if isinstance(products, list) and len(products) > 0:
for p in products:
price = p.get("price", 'Price not available')
if price != 'Price not available':
products_price = price
break
else:
logging.error(f"[{product_name}] Product list is empty or malformed.")
found_in_stock = set()
for p in products:
gpu_name = p.get("fe_sku", "").upper()
is_active = p.get("is_active") == "true"
if is_active and any(upc.upper() in gpu_name for upc in product_upc):
found_in_stock.add(gpu_name)
for upc in product_upc:
upc_upper = upc.upper()
currently_in_stock = upc_upper in found_in_stock
previously_in_stock = global_stock_status_dict.get((product_name, upc_upper), False)
if currently_in_stock and not previously_in_stock:
send_discord_notification(product_name, PRODUCT_URL, products_price)
global_stock_status_dict[(product_name, upc_upper)] = True
logging.info(f"[{product_name}] {upc} is now in stock!")
elif not currently_in_stock and previously_in_stock:
send_out_of_stock_notification(product_name, PRODUCT_URL, products_price)
global_stock_status_dict[(product_name, upc_upper)] = False
logging.info(f"[{product_name}] {upc} is now out of stock.")
elif currently_in_stock:
logging.info(f"[{product_name}] {upc} still in stock.")
else:
logging.info(f"[{product_name}] {upc} still out of stock.")

266
app/localization.json Normal file
View File

@ -0,0 +1,266 @@
{
"en": {
"in_stock_title": "🚀 {gpu_name} IN STOCK!",
"out_of_stock_title": "❌ {gpu_name} is out of stock",
"sku_change_title": "🔄 SKU change detected for {gpu_name}",
"buy_now": "**:point_right: [Buy now]({product_link})**",
"price": "Price",
"time": "Time",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Old SKU** : `{old_sku}`\n**New SKU** : `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Possible imminent drop!"
},
"bg": {
"in_stock_title": "🚀 {gpu_name} ИМА НАЛИЧНОСТ!",
"out_of_stock_title": "❌ {gpu_name} е изчерпан",
"sku_change_title": "🔄 Засечена промяна на SKU за {gpu_name}",
"buy_now": "**:point_right: [Купи сега]({product_link})**",
"price": "Цена",
"time": "Час",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Стар SKU**: `{old_sku}`\n**Нов SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Възможен наближаващ спад!"
},
"cs": {
"in_stock_title": "🚀 {gpu_name} SKLADEM!",
"out_of_stock_title": "❌ {gpu_name} je vyprodán",
"sku_change_title": "🔄 Změna SKU nalezena pro {gpu_name}",
"buy_now": "**:point_right: [Koupit nyní]({product_link})**",
"price": "Cena",
"time": "Čas",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Starý SKU**: `{old_sku}`\n**Nový SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Možný blížící se pokles!"
},
"da": {
"in_stock_title": "🚀 {gpu_name} PÅ LAGER!",
"out_of_stock_title": "❌ {gpu_name} er udsolgt",
"sku_change_title": "🔄 SKU-ændring fundet for {gpu_name}",
"buy_now": "**:point_right: [Køb nu]({product_link})**",
"price": "Pris",
"time": "Tid",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Gammelt SKU**: `{old_sku}`\n**Nyt SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Muligt snarligt drop!"
},
"de": {
"in_stock_title": "🚀 {gpu_name} AUF LAGER!",
"out_of_stock_title": "❌ {gpu_name} ist ausverkauft",
"sku_change_title": "🔄 SKU-Änderung erkannt für {gpu_name}",
"buy_now": "**:point_right: [Jetzt kaufen]({product_link})**",
"price": "Preis",
"time": "Zeit",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Alte SKU**: `{old_sku}`\n**Neue SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Möglicher bevorstehender Drop!"
},
"el": {
"in_stock_title": "🚀 {gpu_name} ΔΙΑΘΕΣΙΜΟ!",
"out_of_stock_title": "❌ {gpu_name} εξαντλήθηκε",
"sku_change_title": "🔄 Ανίχνευση αλλαγής SKU για {gpu_name}",
"buy_now": "**:point_right: [Αγόρασε τώρα]({product_link})**",
"price": "Τιμή",
"time": "Ώρα",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Παλαιό SKU**: `{old_sku}`\n**Νέο SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Πιθανή επικείμενη πτώση!"
},
"es": {
"in_stock_title": "🚀 {gpu_name} ¡EN STOCK!",
"out_of_stock_title": "❌ {gpu_name} está agotado",
"sku_change_title": "🔄 Cambio de SKU detectado para {gpu_name}",
"buy_now": "**:point_right: [Comprar ahora]({product_link})**",
"price": "Precio",
"time": "Hora",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**SKU antiguo**: `{old_sku}`\n**SKU nuevo**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ ¡Posible drop inminente!"
},
"et": {
"in_stock_title": "🚀 {gpu_name} LAOS!",
"out_of_stock_title": "❌ {gpu_name} on välja müüdud",
"sku_change_title": "🔄 Tuvastatud SKU muudatus {gpu_name}",
"buy_now": "**:point_right: [Osta kohe]({product_link})**",
"price": "Hind",
"time": "Aeg",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Vana SKU**: `{old_sku}`\n**Uus SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Võimalik varsti tulemas drop!"
},
"fi": {
"in_stock_title": "🚀 {gpu_name} VARASTOSSA!",
"out_of_stock_title": "❌ {gpu_name} on loppunut",
"sku_change_title": "🔄 SKU-muutos havaittu {gpu_name}",
"buy_now": "**:point_right: [Osta nyt]({product_link})**",
"price": "Hinta",
"time": "Aika",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Vanha SKU**: `{old_sku}`\n**Uusi SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Mahdollinen lähestyvä drop!"
},
"fr": {
"in_stock_title": "🚀 {gpu_name} EN STOCK !",
"out_of_stock_title": "❌ {gpu_name} est en rupture",
"sku_change_title": "🔄 Changement de SKU détecté pour {gpu_name}",
"buy_now": "**:point_right: [Acheter maintenant]({product_link})**",
"price": "Prix",
"time": "Heure",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Ancien SKU** : `{old_sku}`\n**Nouveau SKU** : `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Éventuel drop imminent!"
},
"hr": {
"in_stock_title": "🚀 {gpu_name} NA ZALIHI!",
"out_of_stock_title": "❌ {gpu_name} je rasprodan",
"sku_change_title": "🔄 Promjena SKU za {gpu_name}",
"buy_now": "**:point_right: [Kupi sada]({product_link})**",
"price": "Cijena",
"time": "Vrijeme",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Stari SKU**: `{old_sku}`\n**Novi SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Mogući nadolazeći drop!"
},
"hu": {
"in_stock_title": "🚀 {gpu_name} RAKTÁRON!",
"out_of_stock_title": "❌ {gpu_name} elfogyott",
"sku_change_title": "🔄 SKU változás észlelve: {gpu_name}",
"buy_now": "**:point_right: [Vásárlás most]({product_link})**",
"price": "Ár",
"time": "Idő",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Régi SKU**: `{old_sku}`\n**Új SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Lehetséges közelgő drop!"
},
"ga": {
"in_stock_title": "🚀 {gpu_name} I STOC!",
"out_of_stock_title": "❌ níl {gpu_name} ar fáil",
"sku_change_title": "🔄 SKU athraithe aimsithe do {gpu_name}",
"buy_now": "**:point_right: [Ceannaigh anois]({product_link})**",
"price": "Praghas",
"time": "Am",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Sean SKU**: `{old_sku}`\n**SKU Nua**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ I bhfad is gaire drop féideartha!"
},
"it": {
"in_stock_title": "🚀 {gpu_name} DISPONIBILE!",
"out_of_stock_title": "❌ {gpu_name} esaurito",
"sku_change_title": "🔄 Modifica SKU rilevata per {gpu_name}",
"buy_now": "**:point_right: [Acquista ora]({product_link})**",
"price": "Prezzo",
"time": "Ora",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Vecchio SKU**: `{old_sku}`\n**Nuovo SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Possibile drop imminente!"
},
"lv": {
"in_stock_title": "🚀 {gpu_name} KRĀJUMĀ!",
"out_of_stock_title": "❌ {gpu_name} nav pieejams",
"sku_change_title": "🔄 SKU izmaiņas: {gpu_name}",
"buy_now": "**:point_right: [Pirkt tagad]({product_link})**",
"price": "Cena",
"time": "Laiks",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Vecais SKU**: `{old_sku}`\n**Jaunais SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Iespējams gaidāms drop!"
},
"lt": {
"in_stock_title": "🚀 {gpu_name} SANDĖLYJE!",
"out_of_stock_title": "❌ {gpu_name} išparduotas",
"sku_change_title": "🔄 SKU pakeitimas aptiktas: {gpu_name}",
"buy_now": "**:point_right: [Pirkti dabar]({product_link})**",
"price": "Kaina",
"time": "Laikas",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Senas SKU**: `{old_sku}`\n**Naujas SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Rugsėjo drop'as gali artėti!"
},
"mt": {
"in_stock_title": "🚀 {gpu_name} F'ĠESTA!",
"out_of_stock_title": "❌ {gpu_name} mhux disponibbli",
"sku_change_title": "🔄 Bidla SKU skoperta għal {gpu_name}",
"buy_now": "**:point_right: [Ixtri issa]({product_link})**",
"price": "Prezz",
"time": "Ħin",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**SKU Antik**: `{old_sku}`\n**SKU Ġdid**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Possibbli drop imminenti!"
},
"nl": {
"in_stock_title": "🚀 {gpu_name} OP VOORRAAD!",
"out_of_stock_title": "❌ {gpu_name} is uitverkocht",
"sku_change_title": "🔄 SKU-wijziging gedetecteerd voor {gpu_name}",
"buy_now": "**:point_right: [Nu kopen]({product_link})**",
"price": "Prijs",
"time": "Tijd",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Oude SKU**: `{old_sku}`\n**Nieuwe SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Mogelijke aanstaande drop!"
},
"pl": {
"in_stock_title": "🚀 {gpu_name} DOSTĘPNE!",
"out_of_stock_title": "❌ {gpu_name} jest niedostępny",
"sku_change_title": "🔄 Wykryto zmianę SKU dla {gpu_name}",
"buy_now": "**:point_right: [Kup teraz]({product_link})**",
"price": "Cena",
"time": "Czas",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Stare SKU**: `{old_sku}`\n**Nowe SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Możliwy nadchodzący drop!"
},
"pt": {
"in_stock_title": "🚀 {gpu_name} EM STOCK!",
"out_of_stock_title": "❌ {gpu_name} esgotado",
"sku_change_title": "🔄 Alteração de SKU detectada para {gpu_name}",
"buy_now": "**:point_right: [Comprar agora]({product_link})**",
"price": "Preço",
"time": "Hora",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**SKU antigo**: `{old_sku}`\n**SKU novo**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Possível drop iminente!"
},
"ro": {
"in_stock_title": "🚀 {gpu_name} ÎN STOC!",
"out_of_stock_title": "❌ {gpu_name} este epuizat",
"sku_change_title": "🔄 Schimbare SKU detectată pentru {gpu_name}",
"buy_now": "**:point_right: [Cumpără acum]({product_link})**",
"price": "Preț",
"time": "Timp",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**SKU vechi**: `{old_sku}`\n**SKU nou**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Posibil drop iminent!"
},
"sk": {
"in_stock_title": "🚀 {gpu_name} NA SKLADE!",
"out_of_stock_title": "❌ {gpu_name} je vypredaný",
"sku_change_title": "🔄 Zmena SKU zistená pre {gpu_name}",
"buy_now": "**:point_right: [Kúp teraz]({product_link})**",
"price": "Cena",
"time": "Čas",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Starý SKU**: `{old_sku}`\n**Nový SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Možný nadchádzajúci drop!"
},
"sl": {
"in_stock_title": "🚀 {gpu_name} NA ZALOGI!",
"out_of_stock_title": "❌ {gpu_name} je razprodan",
"sku_change_title": "🔄 Sprememba SKU zaznana za {gpu_name}",
"buy_now": "**:point_right: [Kupi zdaj]({product_link})**",
"price": "Cena",
"time": "Čas",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Stari SKU**: `{old_sku}`\n**Novi SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Možen prihajajoči drop!"
},
"sv": {
"in_stock_title": "🚀 {gpu_name} I LAGER!",
"out_of_stock_title": "❌ {gpu_name} är slut",
"sku_change_title": "🔄 SKU-ändring upptäckt för {gpu_name}",
"buy_now": "**:point_right: [Köp nu]({product_link})**",
"price": "Pris",
"time": "Tid",
"footer": "NviBot • {DISCORD_SERVER_NAME}",
"sku_description": "**Gammalt SKU**: `{old_sku}`\n**Nytt SKU**: `{new_sku}`",
"imminent_drop": "{DISCORD_ROLE} ⚠️ Möjlig förestående drop!"
}
}

27
app/main.py Normal file
View File

@ -0,0 +1,27 @@
import time
import logging
import signal
import sys
from gpu_checker import check_rtx_50_founders
from env_config import REFRESH_TIME
# Signal handler function
def handle_exit(signum, frame):
logging.info(f"🛑 Received signal {signum}. Exiting gracefully...")
sys.exit(0)
# Register signal handlers
signal.signal(signal.SIGINT, handle_exit) # Ctrl+C
signal.signal(signal.SIGTERM, handle_exit) # docker stop / kill -15
if __name__ == "__main__":
try:
while True:
start = time.time()
check_rtx_50_founders()
elapsed = time.time() - start
time.sleep(max(0, REFRESH_TIME - elapsed))
except KeyboardInterrupt:
logging.info("🛑 Script interrupted by user (KeyboardInterrupt). Exiting gracefully.")
sys.exit(0)

112
app/notifier.py Normal file
View File

@ -0,0 +1,112 @@
import time
import logging
import requests
from env_config import (
DISCORD_WEBHOOK_URL, DISCORD_SERVER_NAME, DISCORD_ROLE_MAP, TEST_MODE,
in_stock_title, out_of_stock_title, sku_change_title,
buy_now, price_label, time_label, footer, sku_description, imminent_drop
)
AVATAR = "https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/ds_wh_pp.jpg"
THUMBNAIL = "https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/RTX5000.jpg"
# In stock
def send_discord_notification(gpu_name, product_link, products_price):
timestamp = int(time.time())
if TEST_MODE:
logging.info(f"[TEST MODE] Notification: {gpu_name} available!")
return
embed = {
"title": in_stock_title.format(gpu_name=gpu_name),
"color": 3066993,
"thumbnail": {"url": THUMBNAIL},
"author": {"name": "Nvidia Founder Editions"},
"fields": [
{"name": price_label, "value": f"`{products_price}€`", "inline": True},
{"name": time_label, "value": f"<t:{timestamp}:d> <t:{timestamp}:T>", "inline": True}
],
"description": buy_now.format(product_link=product_link),
"footer": {"text": footer.format(DISCORD_SERVER_NAME=DISCORD_SERVER_NAME), "icon_url": AVATAR}
}
payload = {
"content": DISCORD_ROLE_MAP.get(gpu_name, "@everyone"),
"username": "NviBot",
"avatar_url": AVATAR,
"embeds": [embed]
}
try:
response = requests.post(DISCORD_WEBHOOK_URL, json=payload)
if response.status_code == 204:
logging.info("✅ Notification sent to Discord.")
else:
logging.error(f"❌ Webhook error: {response.status_code} - {response.text}")
except Exception as e:
logging.error(f"🚨 Error sending webhook: {e}")
# Out of stock
def send_out_of_stock_notification(gpu_name, product_link, products_price):
timestamp = int(time.time())
if TEST_MODE:
logging.info(f"[TEST MODE] Out of stock: {gpu_name}")
return
embed = {
"title": out_of_stock_title.format(gpu_name=gpu_name),
"color": 15158332,
"thumbnail": {"url": THUMBNAIL},
"url": product_link,
"author": {"name": "Nvidia Founder Editions"},
"footer": {"text": footer.format(DISCORD_SERVER_NAME=DISCORD_SERVER_NAME), "icon_url": AVATAR},
"fields": [{"name": time_label, "value": f"<t:{timestamp}:d> <t:{timestamp}:T>", "inline": True}]
}
payload = {
"username": "NviBot",
"avatar_url": AVATAR,
"embeds": [embed]
}
try:
response = requests.post(DISCORD_WEBHOOK_URL, json=payload)
if response.status_code == 204:
logging.info("✅ Out-of-stock notification sent.")
else:
logging.error(f"❌ Webhook error: {response.status_code} - {response.text}")
except Exception as e:
logging.error(f"🚨 Error sending webhook: {e}")
# SKU change
def send_sku_change_notification(gpu_name, old_sku, new_sku, product_link):
timestamp = int(time.time())
if TEST_MODE:
logging.info(f"[TEST MODE] SKU change: {old_sku}{new_sku}")
return
embed = {
"title": sku_change_title.format(gpu_name=gpu_name),
"url": product_link,
"description": sku_description.format(old_sku=old_sku, new_sku=new_sku),
"color": 16776960,
"footer": {"text": footer.format(DISCORD_SERVER_NAME=DISCORD_SERVER_NAME), "icon_url": AVATAR},
"fields": [{"name": time_label, "value": f"<t:{timestamp}:d> <t:{timestamp}:T>", "inline": True}]
}
payload = {
"content": imminent_drop.format(DISCORD_ROLE=DISCORD_ROLE_MAP.get(gpu_name, '@everyone')),
"username": "NviBot",
"avatar_url": AVATAR,
"embeds": [embed]
}
try:
response = requests.post(DISCORD_WEBHOOK_URL, json=payload)
if response.status_code == 204:
logging.info("✅ SKU change notification sent.")
else:
logging.error(f"❌ Webhook error: {response.status_code} - {response.text}")
except Exception as e:
logging.error(f"🚨 Error sending webhook: {e}")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,6 +1,5 @@
DS_HOOK= #votre url du webhook Discord
FREQ= #frequence de rafraichissement en secondes
API_URL_SKU= # API listant le produit par exemple https://api.nvidia.partners/edge/product/search?page=1&limit=100&locale=fr-fr&Manufacturer=Nvidia&gpu=RTX%205090
API_URL_STOCK= # API appelant le stock sans préciser la valeur du sku, par exemple https://api.store.nvidia.com/partner/v1/feinventory?locale=fr-fr&skus=
PRODUCT_URL= # URL d'achat du GPU
PRODUCT_NAME= #Le nom du GPU qui s'affiche dans les notifications
PRODUCT_NAMES= # Exact GPU name (e.g. "RTX 5080, RTX 5090")
DISCORD_WEBHOOK_URL= # Your Discord webhook URL
API_URL_SKU= # API listing the product for your country
API_URL_STOCK= # API providing stock data for your country
PRODUCT_URL= # GPU purchase URL for your country

View File

@ -1,15 +1,15 @@
version: "3.8"
services:
nvidia-stock-bot:
image: nvidia-stock-bot
container_name: nvidia-stock-bot
restart: always # Le conteneur redémarrera automatiquement en cas d'échec
restart: unless-stopped
env_file:
- .env
environment:
- DISCORD_WEBHOOK_URL=${DS_HOOK}
- REFRESH_TIME=${FREQ}
- API_URL_SKU=${API_URL_SKU}
- API_URL_STOCK=${API_URL_STOCK}
- PYTHONUNBUFFERED=1 # Permet d'afficher les logs en temps réel
command: python nvidia-stock-bot.py # Lance le script Python
# Minimal environment variables
- PRODUCT_NAMES=${PRODUCT_NAMES} # Exact GPU name (e.g. "RTX 5080, RTX 5090")
- DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} # Your Discord webhook URL
- API_URL_SKU=${API_URL_SKU} # API listing the product for your country
- API_URL_STOCK=${API_URL_STOCK} # API providing stock data for your country
- PRODUCT_URL=${PRODUCT_URL} # GPU purchase URL for your country
- PYTHONUNBUFFERED=1 # Enables real-time log output

View File

@ -1,335 +0,0 @@
import requests
import logging
import time
import os
import re
from requests.adapters import HTTPAdapter, Retry
# Configuration du logger
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logging.info("Démarrage du script")
# Récupération des variables d'environnement
try:
DISCORD_WEBHOOK_URL = os.environ['DISCORD_WEBHOOK_URL']
API_URL_SKU = os.environ['API_URL_SKU']
API_URL_STOCK = os.environ['API_URL_STOCK']
REFRESH_TIME = int(os.environ['REFRESH_TIME']) # Convertir en entier
TEST_MODE = os.environ.get('TEST_MODE', 'False').lower() == 'true'
PRODUCT_URL = os.environ['PRODUCT_URL']
PRODUCT_NAME = os.environ['PRODUCT_NAME']
# Regex pour extraire l'ID et le token
match = re.search(r'/(\d+)/(.*)', DISCORD_WEBHOOK_URL)
if match:
webhook_id = match.group(1)
webhook_token = match.group(2)
# Masquer derniers caractères de l'ID
masked_webhook_id = webhook_id[:len(webhook_id) - 10] + '*' * 10
# Masquer derniers caractères du token
masked_webhook_token = webhook_token[:len(webhook_token) - 120] + '*' * 10
# Reconstruction de l'url masquée
wh_masked_url = f"https://discord.com/api/webhooks/{masked_webhook_id}/{masked_webhook_token}"
except KeyError as e:
logging.error(f"Variable d'environnement manquante : {e}")
exit(1)
except ValueError:
logging.error("REFRESH_TIME doit être un entier valide.")
exit(1)
# Affichage des URLs et configurations
logging.info(f"GPU: {PRODUCT_NAME}")
logging.info(f"URL Webhook Discord: {wh_masked_url}")
logging.info(f"URL API SKU: {API_URL_SKU}")
logging.info(f"URL API Stock: {API_URL_STOCK}")
logging.info(f"URL produit: {PRODUCT_URL}")
logging.info(f"Temps d'actualisation: {REFRESH_TIME} secondes")
logging.info(f"Mode Test: {TEST_MODE}")
# Entêtes HTTP
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.5",
"Cache-Control": "max-age=0",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"Sec-GPC": "1",
}
# Session avec retries
session = requests.Session()
retries = Retry(total=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
session.mount('https://', HTTPAdapter(max_retries=retries))
# Stockage de l'état des stocks
global_stock_status = {}
# Stocke le dernier SKU connu
last_sku = None
first_run = True # Before calling check_rtx_50_founders
# Notifications Discord
def send_discord_notification(gpu_name: str, product_link: str, products_price: str):
# Récupérer le timestamp UNIX actuel
timestamp_unix = int(time.time())
if TEST_MODE:
logging.info(f"[TEST MODE] Notification Discord: {gpu_name} disponible !")
return
embed = {
"title": f"🚀 {PRODUCT_NAME} EN STOCK !",
"color": 3066993,
"thumbnail": {
"url": "https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/RTX5000.jpg"
},
"author": {
"name": "Nvidia Founder Editions"
},
"fields": [
{
"name": "Prix",
"value": f"`{products_price}€`",
"inline": True
},
{
"name": "Heure",
"value": f"<t:{timestamp_unix}:d> <t:{timestamp_unix}:T>",
"inline": True
},
],
"description": f"**:point_right: [Acheter maintenant]({product_link})**",
"footer": {
"text": "NviBot • JV Hardware 2.0",
"icon_url": "https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/ds_wh_pp.jpg"
}
}
payload = {"content": "@everyone", "username": "NviBot", "avatar_url": "https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/ds_wh_pp.jpg", "embeds": [embed]}
try:
response = requests.post(DISCORD_WEBHOOK_URL, json=payload)
if response.status_code == 204:
logging.info("✅ Notification envoyée sur Discord.")
else:
logging.error(f"❌ Erreur Webhook : {response.status_code} - {response.text}")
except Exception as e:
logging.error(f"🚨 Erreur lors de l'envoi du webhook : {e}")
def send_out_of_stock_notification(gpu_name: str, product_link: str, products_price: str):
# Récupérer le timestamp UNIX actuel
timestamp_unix = int(time.time())
if TEST_MODE:
logging.info(f"[TEST MODE] Notification Discord: {gpu_name} hors stock !")
return
embed = {
"title": f"{PRODUCT_NAME} n'est plus en stock",
"color": 15158332, # Rouge pour hors stock
"thumbnail": {
"url": "https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/RTX5000.jpg"
},
"url": f"{product_link}",
"author": {
"name": "Nvidia Founder Editions"
},
"footer": {
"text": "NviBot • JV Hardware 2.0",
"icon_url": "https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/ds_wh_pp.jpg"
},
"fields": [
{
"name": "Heure",
"value": f"<t:{timestamp_unix}:d> <t:{timestamp_unix}:T>",
"inline": True
}
]
}
payload = {"username": "NviBot", "avatar_url": "https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/ds_wh_pp.jpg", "embeds": [embed]}
try:
response = requests.post(DISCORD_WEBHOOK_URL, json=payload)
if response.status_code == 204:
logging.info("✅ Notification 'hors stock' envoyée sur Discord.")
else:
logging.error(f"❌ Erreur Webhook : {response.status_code} - {response.text}")
except Exception as e:
logging.error(f"🚨 Erreur lors de l'envoi du webhook : {e}")
def send_sku_change_notification(old_sku: str, new_sku: str, product_link: str):
# Récupérer le timestamp UNIX actuel
timestamp_unix = int(time.time())
if TEST_MODE:
logging.info(f"[TEST MODE] Changement de SKU détecté : {old_sku}{new_sku}")
return
embed = {
"title": f"🔄 {PRODUCT_NAME} Changement de SKU détecté",
"url": f"{product_link}",
"description": f"**Ancien SKU** : `{old_sku}`\n**Nouveau SKU** : `{new_sku}`",
"color": 16776960, # Jaune
"footer": {
"text": "NviBot • JV Hardware 2.0",
"icon_url": "https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/ds_wh_pp.jpg"
},
"fields": [
{
"name": "Heure",
"value": f"<t:{timestamp_unix}:d> <t:{timestamp_unix}:T>",
"inline": True
}
]
}
payload = {
"content": "@everyone ⚠️ Potentiel drop imminent !",
"username": "NviBot",
"avatar_url": "https://git.djeex.fr/Djeex/nvidia-stock-bot/raw/branch/main/assets/img/ds_wh_pp.jpg",
"embeds": [embed]
}
try:
response = requests.post(DISCORD_WEBHOOK_URL, json=payload)
if response.status_code == 204:
logging.info("✅ Notification de changement de SKU envoyée sur Discord.")
else:
logging.error(f"❌ Erreur Webhook : {response.status_code} - {response.text}")
except Exception as e:
logging.error(f"🚨 Erreur lors de l'envoi du webhook : {e}")
# Recherche du stock
def check_rtx_50_founders():
global global_stock_status, last_sku, first_run
# Appel vers l'API produit pour récupérer le sku et l'upc
try:
response = session.get(API_URL_SKU, headers=HEADERS, timeout=10)
logging.info(f"Réponse de l'API SKU : {response.status_code}")
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
logging.error(f"Erreur API SKU : {e}")
return
# Recherche du produit dont le GPU correspond à PRODUCT_NAME
product_details = None
for p in data['searchedProducts']['productDetails']:
gpu_name = p.get("gpu", "").strip()
# Si le GPU correspond exactement à PRODUCT_NAME
if gpu_name == PRODUCT_NAME.strip():
product_details = p
break # Sortir dès qu'on trouve le bon produit
if not product_details:
logging.warning(f"⚠️ Aucun produit avec le GPU '{PRODUCT_NAME}' trouvé.")
return
# Récupérer le SKU pour le GPU trouvé
product_sku = product_details['productSKU']
product_upc = product_details.get('productUPC', "")
# Vérifier si c'est la première exécution
if last_sku is not None and product_sku != last_sku:
if not first_run: # Évite d'envoyer une notification au premier appel
product_link = PRODUCT_URL
logging.warning(f"⚠️ SKU modifié : {last_sku}{product_sku}")
send_sku_change_notification(last_sku, product_sku, product_link)
# Mettre à jour le SKU stocké
last_sku = product_sku
first_run = False # Désactive la protection après la première exécution
if not isinstance(product_upc, list):
product_upc = [product_upc]
# Construction de l'url de l'API de stock et appel pour vérifier le statut
API_URL = API_URL_STOCK + product_sku
logging.info(f"URL de l'API de stock appelée : {API_URL}")
try:
response = session.get(API_URL, headers=HEADERS, timeout=10)
logging.info(f"Réponse de l'API : {response.status_code}")
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
logging.error(f"Erreur API Stock : {e}")
return
products = data.get("listMap", [])
products_price = 'Prix non disponible' # Valeur par défaut
# Vérification de la liste des produits et récupération du prix
if isinstance(products, list) and len(products) > 0:
for product in products:
price = product.get("price", 'Prix non disponible')
if price != 'Prix non disponible':
products_price = price # Utiliser le prix trouvé
break # Sortir dès qu'on trouve un prix
else:
logging.error("La liste des produits est vide ou mal formée.")
found_in_stock = set()
# Recherche du statut et notifications selon le statut
for p in products:
gpu_name = p.get("fe_sku", "").upper()
is_active = p.get("is_active") == "true"
if is_active and any(target.upper() in gpu_name for target in product_upc):
found_in_stock.add(gpu_name)
for gpu in product_upc:
gpu_upper = gpu.upper()
currently_in_stock = gpu_upper in found_in_stock
previously_in_stock = global_stock_status.get(gpu_upper, False)
if currently_in_stock and not previously_in_stock:
product_link = PRODUCT_URL
send_discord_notification(gpu_upper, product_link, products_price)
global_stock_status[gpu_upper] = True
logging.info(f"{gpu} est maintenant en stock!")
elif not currently_in_stock and previously_in_stock:
product_link = PRODUCT_URL
send_out_of_stock_notification(gpu_upper, product_link, products_price)
global_stock_status[gpu_upper] = False
logging.info(f"{gpu} n'est plus en stock.")
elif currently_in_stock and previously_in_stock:
logging.info(f"{gpu} est actuellement en stock.")
else:
logging.info(f"{gpu} est actuellement hors stock.")
# Boucle
if __name__ == "__main__":
while True:
check_rtx_50_founders()
time.sleep(REFRESH_TIME)