feat: update stock monitoring to include Discord user mentions and validation
This commit is contained in:
@@ -8,6 +8,6 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY monitor.py .
|
COPY main.py .
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|||||||
95
main.py
95
main.py
@@ -9,9 +9,12 @@ import requests
|
|||||||
# Configuration
|
# Configuration
|
||||||
# =========================
|
# =========================
|
||||||
|
|
||||||
PRODUCT_URL = f"{os.environ["PRODUCT_URL"]}.js"
|
DISCORD_USER_ID_TO_PING = "736200712169455656"
|
||||||
TARGET_VARIANT_ID = os.environ["TARGET_VARIANT_ID"]
|
DISCORD_MENTION = f"<@{DISCORD_USER_ID_TO_PING}>"
|
||||||
DISCORD_WEBHOOK_URL = os.environ["DISCORD_WEBHOOK_URL"]
|
|
||||||
|
PRODUCT_URL = f"{os.environ.get('PRODUCT_URL', '').rstrip('/')}.js"
|
||||||
|
TARGET_VARIANT_ID_RAW = os.environ.get("TARGET_VARIANT_ID", "")
|
||||||
|
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "")
|
||||||
|
|
||||||
CHECK_INTERVAL_SECONDS = 30
|
CHECK_INTERVAL_SECONDS = 30
|
||||||
POST_DETECTION_COOLDOWN_SECONDS = 600
|
POST_DETECTION_COOLDOWN_SECONDS = 600
|
||||||
@@ -36,54 +39,101 @@ console_handler.setFormatter(log_format)
|
|||||||
logger.addHandler(console_handler)
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_env(name: str, value: str) -> str:
|
||||||
|
"""Return the env var value; raises if missing or empty."""
|
||||||
|
value = (value or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_variant_id(raw: str) -> str:
|
||||||
|
"""Return a string form of the variant id; raises if invalid."""
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise RuntimeError("TARGET_VARIANT_ID is empty")
|
||||||
|
try:
|
||||||
|
return str(int(raw))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError("TARGET_VARIANT_ID must be an integer-like value") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config() -> tuple[str, str, str]:
|
||||||
|
"""Validate required config and return normalized (product_url, variant_id, webhook_url)."""
|
||||||
|
product_url = _require_env("PRODUCT_URL", PRODUCT_URL)
|
||||||
|
variant_id = _normalize_variant_id(TARGET_VARIANT_ID_RAW)
|
||||||
|
webhook_url = _require_env("DISCORD_WEBHOOK_URL", DISCORD_WEBHOOK_URL)
|
||||||
|
|
||||||
|
if not webhook_url.startswith("https://"):
|
||||||
|
raise RuntimeError("DISCORD_WEBHOOK_URL must start with https://")
|
||||||
|
|
||||||
|
return product_url, variant_id, webhook_url
|
||||||
|
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# Discord Notification
|
# Discord Notification
|
||||||
# =========================
|
# =========================
|
||||||
|
|
||||||
def send_discord_notification(product_name, variant_name, checkout_link):
|
def send_discord_notification(product_name, variant_name, checkout_link):
|
||||||
"""
|
"""Sends a stock alert to Discord using a webhook."""
|
||||||
Sends a stock alert to Discord using a webhook.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.critical("Stock detected! Sending Discord notification.")
|
logger.critical("Stock detected! Sending Discord notification.")
|
||||||
|
|
||||||
payload = {"content": ("🚨 **RESTOCK ALERT** 🚨\n\n"
|
payload = {"content": (f"{DISCORD_MENTION}\n"
|
||||||
|
"🚨 **RESTOCK ALERT** 🚨\n\n"
|
||||||
f"**{product_name}**\n"
|
f"**{product_name}**\n"
|
||||||
f"Variant: {variant_name}\n\n"
|
f"Variant: {variant_name}\n\n"
|
||||||
f"👉 [ONE-CLICK CHECKOUT]({checkout_link})")}
|
f"👉 [ONE-CLICK CHECKOUT]({checkout_link})")}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(DISCORD_WEBHOOK_URL, json=payload)
|
response = requests.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10)
|
||||||
|
|
||||||
if response.status_code == 204:
|
if response.status_code == 204:
|
||||||
logger.info("Discord notification sent successfully.")
|
logger.info("Discord notification sent successfully.")
|
||||||
else:
|
else:
|
||||||
logger.error("Discord notification failed. "
|
logger.error("Discord notification failed. "
|
||||||
f"HTTP status code: {response.status_code}")
|
f"HTTP status code: {response.status_code}; response: {response.text[:500]}")
|
||||||
|
|
||||||
except Exception as exception:
|
except Exception as exception:
|
||||||
logger.error("Failed to send Discord notification due to an exception.", exc_info=exception)
|
logger.error("Failed to send Discord notification due to an exception.", exc_info=exception)
|
||||||
|
|
||||||
|
|
||||||
|
def send_startup_test_ping():
|
||||||
|
"""Sends a one-time hello-world message to validate webhook + mentions."""
|
||||||
|
logger.info("Sending startup Discord test ping...")
|
||||||
|
|
||||||
|
payload = {"content": (f"{DISCORD_MENTION} Hello world! "
|
||||||
|
"✅ Webhook connection is working and user mention should ping.")}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 204:
|
||||||
|
logger.info("Startup Discord test ping sent successfully.")
|
||||||
|
else:
|
||||||
|
logger.error("Startup Discord test ping failed. "
|
||||||
|
f"HTTP status code: {response.status_code}; response: {response.text[:500]}")
|
||||||
|
|
||||||
|
except Exception as exception:
|
||||||
|
logger.error("Failed to send startup test ping due to an exception.", exc_info=exception)
|
||||||
|
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# Stock Monitoring Logic
|
# Stock Monitoring Logic
|
||||||
# =========================
|
# =========================
|
||||||
|
|
||||||
def check_stock_continuously():
|
def check_stock_continuously():
|
||||||
"""
|
"""Continuously checks the product JSON endpoint to see if the target variant is available for purchase."""
|
||||||
Continuously checks the product JSON endpoint to see if the target
|
product_url, target_variant_id, _ = validate_config()
|
||||||
variant is available for purchase.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.info(f"Stock monitor started. Watching variant ID: {TARGET_VARIANT_ID}")
|
logger.info(f"Stock monitor started. Watching variant ID: {target_variant_id}")
|
||||||
|
|
||||||
request_headers = {"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
request_headers = {"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
"Chrome/120.0.0.0 Safari/537.36"), "Accept": "application/json"}
|
"Chrome/120.0.0.0 Safari/537.36"), "Accept": "application/json", }
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
response = requests.get(PRODUCT_URL, headers=request_headers, timeout=10)
|
response = requests.get(product_url, headers=request_headers, timeout=10)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"HTTP request failed. Status code: {response.status_code}")
|
logger.error(f"HTTP request failed. Status code: {response.status_code}")
|
||||||
@@ -94,10 +144,11 @@ def check_stock_continuously():
|
|||||||
product_name = product_data.get("title", "FormD T1")
|
product_name = product_data.get("title", "FormD T1")
|
||||||
variants = product_data.get("variants", [])
|
variants = product_data.get("variants", [])
|
||||||
|
|
||||||
target_variant = next((variant for variant in variants if variant.get("id") == TARGET_VARIANT_ID), None)
|
target_variant = next((variant for variant in variants if str(variant.get("id")) == target_variant_id),
|
||||||
|
None, )
|
||||||
|
|
||||||
if not target_variant:
|
if not target_variant:
|
||||||
logger.error(f"Variant ID {TARGET_VARIANT_ID} was not found in the product data.")
|
logger.error(f"Variant ID {target_variant_id} was not found in the product data.")
|
||||||
time.sleep(CHECK_INTERVAL_SECONDS)
|
time.sleep(CHECK_INTERVAL_SECONDS)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -107,7 +158,7 @@ def check_stock_continuously():
|
|||||||
if is_in_stock:
|
if is_in_stock:
|
||||||
logger.warning(f"Stock available! Variant detected: {variant_name}")
|
logger.warning(f"Stock available! Variant detected: {variant_name}")
|
||||||
|
|
||||||
checkout_link = (f"https://formdt1.com/cart/{TARGET_VARIANT_ID}:1")
|
checkout_link = f"https://formdt1.com/cart/{target_variant_id}:1"
|
||||||
|
|
||||||
send_discord_notification(product_name, variant_name, checkout_link)
|
send_discord_notification(product_name, variant_name, checkout_link)
|
||||||
|
|
||||||
@@ -123,7 +174,7 @@ def check_stock_continuously():
|
|||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
logger.warning("Request timed out. The server may be slow or blocking requests.")
|
logger.warning("Request timed out. The server may be slow or blocking requests.")
|
||||||
|
|
||||||
except Exception as exception:
|
except Exception:
|
||||||
logger.exception("An unexpected error occurred during the stock check.")
|
logger.exception("An unexpected error occurred during the stock check.")
|
||||||
|
|
||||||
time.sleep(CHECK_INTERVAL_SECONDS)
|
time.sleep(CHECK_INTERVAL_SECONDS)
|
||||||
@@ -134,4 +185,6 @@ def check_stock_continuously():
|
|||||||
# =========================
|
# =========================
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
validate_config()
|
||||||
|
send_startup_test_ping()
|
||||||
check_stock_continuously()
|
check_stock_continuously()
|
||||||
|
|||||||
Reference in New Issue
Block a user