Add multi-channel Discord webhook routing

Route notifications to both site-specific and type-specific channels simultaneously. Includes site name normalization for matching config keys.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 22:16:50 -04:00
parent d33639fdc7
commit 74c2a45d50
2 changed files with 284 additions and 25 deletions
+49 -2
View File
@@ -5,13 +5,60 @@ Update DISCORD_WEBHOOK_URL with your actual webhook URL
# Discord webhook URL - GET THIS FROM YOUR DISCORD SERVER # Discord webhook URL - GET THIS FROM YOUR DISCORD SERVER
# Server Settings -> Integrations -> Webhooks -> New Webhook -> Copy Webhook URL # Server Settings -> Integrations -> Webhooks -> New Webhook -> Copy Webhook URL
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/1471927173353963570/8Xh4Y_D8zRDi5MAP6ZlX0rf2Fc5wEtRKCup74kBZltk2qaatxArFp-yrk7ZWh5EOZbHp" # This is the default/fallback webhook for anything not specifically routed below
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/1488982276455796756/4HEzQDBF-Hs4a9Iyd7yvj7-SfP0BwrbQGdGBmQ9TL9urWzkTRaHelkvrqX4v9NTf2ZJ_"
# =============================================================================
# MULTI-CHANNEL WEBHOOK ROUTING (Optional)
# =============================================================================
# Route notifications to different Discord channels based on site and/or alert type.
# Leave webhooks as None to use the default DISCORD_WEBHOOK_URL above.
#
# Create webhooks in Discord: Server Settings -> Integrations -> Webhooks -> New Webhook
#
# Lookup priority:
# 1. overrides (specific site + type combo)
# 2. sites (all alerts for a specific store)
# 3. types (all alerts of a specific type)
# 4. default (DISCORD_WEBHOOK_URL)
DISCORD_WEBHOOKS = {
# Per-site channels - all alerts from a specific store go here first
"sites": {
"target": "https://discord.com/api/webhooks/1488982720901025852/O0KkVFHrQ6-k6FmCqQE4QpuWF6YFJ8YyFgojBPKcQu9g0UHzTpnAU9G1gbhOftARS11R",
"bestbuy": "https://discord.com/api/webhooks/1488982786781216798/AtG6x7FWzJjOsmosq9oPMuwlPrSwNHZJJTUuB3AApjyT7LIOxmEcYeiMFP7HiA_Ov1pn",
"gamestop": "https://discord.com/api/webhooks/1488982854036619355/NWZtydrKEhj-kxKbFi78f1Gpf3h16W_1kAJEcSuIgtjJUoAO8Sk7inOI2T8fb9GXo9xY",
"walmart": "https://discord.com/api/webhooks/1488982929450340375/0g16tJXw8f9UtUrRrLd2jDPWWD__LTDYO_X2VT2WL4F5GBYB8ykVxrjV-bCjzKKjSA5C",
"pokemoncenter": "https://discord.com/api/webhooks/1488983050090975364/DwY9QQvBZa6V-zz8mIg302sukbISSbkXOhDwe1Q6yDG0zAwvaGQcn8iTzNB4D676e3cA",
},
# Per-type channels - used when no site-specific webhook is set
"types": {
"restock": "https://discord.com/api/webhooks/1488982403048542269/s1J_p8R6IMGQGypPh_ZYTHHBD0cMnd_BjLGggVWaL2OcyDQAR42KS-CyuHcVr5s21vl3",
"new_drop": "https://discord.com/api/webhooks/1488982511613771898/RLDoHxNjsAx0PfHfT0kZe6-IW3g9PR_ckX_sI-CALcv4Lt0USslvI0rqePqW9gVP7D7v",
"price_change": "https://discord.com/api/webhooks/1488982573723025633/J864UL4vk_q9Rmj4i4wtkGfzB3nKAOc0y7jzBFDgUbhwg4XKm1Z7sWduqRc6x_ThWetc",
"error": "https://discord.com/api/webhooks/1488983128641900657/RP5FRSWwbVaRAf7RdXPs66ETbY8ziI1ekIx7lrHqxp8n200KpzUiX_EylCxXBH5nJD3B",
"captcha": "https://discord.com/api/webhooks/1488983128641900657/RP5FRSWwbVaRAf7RdXPs66ETbY8ziI1ekIx7lrHqxp8n200KpzUiX_EylCxXBH5nJD3B",
},
# Override specific site + type combinations (highest priority)
# Use tuples: ("site", "type"): "webhook_url"
"overrides": {
# ("bestbuy", "restock"): "https://discord.com/api/webhooks/xxx/bestbuy-restocks",
# ("pokemoncenter", "new_drop"): "https://discord.com/api/webhooks/xxx/pokemon-drops",
},
}
# Enable/disable Discord notifications # Enable/disable Discord notifications
NOTIFICATIONS_ENABLED = True # Set to False to disable all Discord alerts NOTIFICATIONS_ENABLED = True # Set to False to disable all Discord alerts
NEW_DROP_NOTIFICATIONS = True # Set to False to silence new drop notifications NEW_DROP_NOTIFICATIONS = True # Set to False to silence new drop notifications
PRICE_CHANGE_NOTIFICATIONS = True # Set to False to silence price change notifications
SKIP_DUPLICATE_SKUS = True # Skip notifications for products we've already notified about SKIP_DUPLICATE_SKUS = True # Skip notifications for products we've already notified about
# Restock notification cooldown (prevents duplicate alerts for same product)
# Set to number of hours before a product can trigger another restock notification
RESTOCK_COOLDOWN_HOURS = 6 # Minimum hours between restock alerts for same product
# Discord Bot Token (for interactive bot commands) # Discord Bot Token (for interactive bot commands)
# Create a bot at https://discord.com/developers/applications # Create a bot at https://discord.com/developers/applications
# Required for !setlocation, !stores, !stock commands # Required for !setlocation, !stores, !stock commands
@@ -36,7 +83,7 @@ SORT_BY_NEWEST = True
# Which sites to monitor # Which sites to monitor
# Note: PokemonCenter has strong bot protection (Imperva) - may need manual workarounds # Note: PokemonCenter has strong bot protection (Imperva) - may need manual workarounds
# Note: Walmart has aggressive bot protection (PerimeterX) - may need stealth mode # Note: Walmart has aggressive bot protection (PerimeterX) - may need stealth mode
SITES_ENABLED = ["target", "gamestop", "bestbuy"] # Options: "pokemoncenter", "target", "gamestop", "bestbuy" (disabled - needs rework), "walmart" SITES_ENABLED = ["target", "bestbuy", "gamestop"] # Options: "pokemoncenter", "target", "gamestop", "bestbuy" (disabled - needs rework), "walmart"
# PokemonCenter URLs to monitor # PokemonCenter URLs to monitor
POKEMON_CENTER_URLS = [ POKEMON_CENTER_URLS = [
+235 -23
View File
@@ -10,10 +10,69 @@ import requests
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Set from typing import Optional, Dict, Set
from config import DISCORD_WEBHOOK_URL, NOTIFICATIONS_ENABLED, SKIP_DUPLICATE_SKUS from config import DISCORD_WEBHOOK_URL, DISCORD_WEBHOOKS, NOTIFICATIONS_ENABLED, SKIP_DUPLICATE_SKUS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _normalize_site_name(site: str) -> str:
"""Normalize site name to match config keys (lowercase, no spaces)."""
if not site:
return ""
# Remove spaces and convert to lowercase
# "Best Buy" -> "bestbuy", "Pokemon Center" -> "pokemoncenter"
return site.lower().replace(" ", "").replace("-", "")
def get_webhooks(site: str = None, alert_type: str = None) -> list:
"""
Get all applicable webhook URLs based on site and alert type.
Sends to BOTH site-specific AND type-specific channels.
Args:
site: Site name (target, bestbuy, gamestop, walmart, pokemoncenter)
alert_type: Alert type (restock, new_drop, price_change, error, captcha)
Returns:
List of webhook URLs to send to (deduplicated)
"""
webhooks = []
normalized_site = _normalize_site_name(site)
normalized_type = alert_type.lower() if alert_type else None
# Check for specific override first (highest priority - replaces both site and type)
if normalized_site and normalized_type:
overrides = DISCORD_WEBHOOKS.get("overrides", {})
override_url = overrides.get((normalized_site, normalized_type))
if override_url:
return [override_url] # Override replaces all other routing
# Add site-specific webhook
if normalized_site:
sites = DISCORD_WEBHOOKS.get("sites", {})
site_url = sites.get(normalized_site)
if site_url:
webhooks.append(site_url)
# Add type-specific webhook
if normalized_type:
types = DISCORD_WEBHOOKS.get("types", {})
type_url = types.get(normalized_type)
if type_url and type_url not in webhooks:
webhooks.append(type_url)
# Fall back to default if no specific webhooks found
if not webhooks:
webhooks.append(DISCORD_WEBHOOK_URL)
return webhooks
def get_webhook(site: str = None, alert_type: str = None) -> str:
"""Legacy single-webhook function for backwards compatibility."""
webhooks = get_webhooks(site, alert_type)
return webhooks[0] if webhooks else DISCORD_WEBHOOK_URL
# Track notified products to avoid duplicates # Track notified products to avoid duplicates
NOTIFIED_FILE = Path(__file__).parent.parent / "data" / "notified_products.json" NOTIFIED_FILE = Path(__file__).parent.parent / "data" / "notified_products.json"
_notified_urls: Set[str] = set() _notified_urls: Set[str] = set()
@@ -56,6 +115,7 @@ def mark_as_notified(product_url: str):
COLOR_RESTOCK = 0x00FF00 # Green - item back in stock COLOR_RESTOCK = 0x00FF00 # Green - item back in stock
COLOR_NEW_DROP = 0x0099FF # Blue - new product listing COLOR_NEW_DROP = 0x0099FF # Blue - new product listing
COLOR_PREORDER = 0xFFAA00 # Orange - pre-order available COLOR_PREORDER = 0xFFAA00 # Orange - pre-order available
COLOR_PRICE_CHANGE = 0x9B59B6 # Purple - price changed
COLOR_ERROR = 0xFF0000 # Red - error notification COLOR_ERROR = 0xFF0000 # Red - error notification
COLOR_FAVORITE_HIGH = 0xFF0000 # Red - high priority favorite COLOR_FAVORITE_HIGH = 0xFF0000 # Red - high priority favorite
COLOR_FAVORITE_MEDIUM = 0xFFAA00 # Orange - medium priority favorite COLOR_FAVORITE_MEDIUM = 0xFFAA00 # Orange - medium priority favorite
@@ -109,12 +169,13 @@ def send_stock_alert(
logger.debug(f"Already notified about this product - skipping: {product_name}") logger.debug(f"Already notified about this product - skipping: {product_name}")
return True return True
# Determine which webhook to use # Determine which webhooks to use (priority: custom > routing > default)
webhook_url = DISCORD_WEBHOOK_URL
if priority_settings and priority_settings.get('custom_webhook'): if priority_settings and priority_settings.get('custom_webhook'):
webhook_url = priority_settings['custom_webhook'] webhook_urls = [priority_settings['custom_webhook']]
else:
webhook_urls = get_webhooks(site=site, alert_type=alert_type)
if webhook_url == "YOUR_WEBHOOK_URL_HERE": if not webhook_urls or webhook_urls[0] == "YOUR_WEBHOOK_URL_HERE":
logger.error("Discord webhook URL not configured! Update config.py") logger.error("Discord webhook URL not configured! Update config.py")
return False return False
@@ -189,21 +250,26 @@ def send_stock_alert(
"embeds": [embed], "embeds": [embed],
} }
try: # Send to all applicable webhooks
response = requests.post( success = False
webhook_url, for webhook_url in webhook_urls:
json=payload, try:
timeout=10, response = requests.post(
) webhook_url,
response.raise_for_status() json=payload,
logger.info(f"Discord notification sent for: {product_name} (priority: {priority})") timeout=10,
)
response.raise_for_status()
success = True
except requests.exceptions.RequestException as e:
logger.error(f"Failed to send Discord notification to {webhook_url}: {e}")
if success:
logger.info(f"Discord notification sent for: {product_name} to {len(webhook_urls)} channel(s)")
# Track that we notified about this product # Track that we notified about this product
if alert_type == "new_drop": if alert_type == "new_drop":
mark_as_notified(product_url) mark_as_notified(product_url)
return True return success
except requests.exceptions.RequestException as e:
logger.error(f"Failed to send Discord notification: {e}")
return False
def send_stock_alert_with_priority(product, alert_type: str = "restock", def send_stock_alert_with_priority(product, alert_type: str = "restock",
@@ -231,7 +297,9 @@ def send_error_notification(error_message: str, site: str = "Unknown"):
"""Send an error notification to Discord""" """Send an error notification to Discord"""
if not NOTIFICATIONS_ENABLED: if not NOTIFICATIONS_ENABLED:
return True return True
if DISCORD_WEBHOOK_URL == "YOUR_WEBHOOK_URL_HERE":
webhook_urls = get_webhooks(site=site, alert_type="error")
if not webhook_urls or webhook_urls[0] == "YOUR_WEBHOOK_URL_HERE":
return False return False
embed = { embed = {
@@ -245,14 +313,158 @@ def send_error_notification(error_message: str, site: str = "Unknown"):
payload = {"embeds": [embed]} payload = {"embeds": [embed]}
try: success = False
response = requests.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10) for webhook_url in webhook_urls:
response.raise_for_status() try:
response = requests.post(webhook_url, json=payload, timeout=10)
response.raise_for_status()
success = True
except requests.exceptions.RequestException as e:
logger.error(f"Failed to send error notification: {e}")
return success
def send_captcha_alert(site: str, url: str, message: str = None):
"""
Send a Discord notification when CAPTCHA is detected and human intervention is needed.
Args:
site: Site name where CAPTCHA was detected (e.g., "PokemonCenter")
url: URL that triggered the CAPTCHA
message: Optional custom message
"""
if not NOTIFICATIONS_ENABLED:
return True return True
except requests.exceptions.RequestException as e:
logger.error(f"Failed to send error notification: {e}") webhook_urls = get_webhooks(site=site, alert_type="captcha")
if not webhook_urls or webhook_urls[0] == "YOUR_WEBHOOK_URL_HERE":
return False return False
default_message = "Human intervention required to solve CAPTCHA. The scraper is paused until resolved."
description = message or default_message
embed = {
"title": "\U0001F6A7 CAPTCHA DETECTED - Action Required",
"description": description,
"color": 0xFF6600, # Orange color for warning
"fields": [
{"name": "Site", "value": site, "inline": True},
{"name": "URL", "value": f"[Open Page]({url})", "inline": True},
{"name": "Action Needed", "value": "Please solve the CAPTCHA manually to resume monitoring.", "inline": False},
],
"footer": {"text": "Pokemon Stock Monitor"},
"timestamp": datetime.utcnow().isoformat(),
}
payload = {
"content": "@everyone \U0001F6A7 CAPTCHA requires manual intervention!",
"embeds": [embed]
}
success = False
for webhook_url in webhook_urls:
try:
response = requests.post(webhook_url, json=payload, timeout=10)
response.raise_for_status()
success = True
except requests.exceptions.RequestException as e:
logger.error(f"Failed to send CAPTCHA alert: {e}")
if success:
logger.info(f"CAPTCHA alert sent for {site}")
return success
def send_price_change_alert(
product_name: str,
product_url: str,
old_price: str,
new_price: str,
site: str,
in_stock: bool = True,
image_url: Optional[str] = None,
):
"""
Send a Discord notification when a product's price changes.
Args:
product_name: Name of the product
product_url: Direct link to the product
old_price: Previous price string
new_price: New price string
site: Site name (target, bestbuy, etc.)
in_stock: Whether the product is currently in stock
image_url: Optional product image URL
"""
if not NOTIFICATIONS_ENABLED:
logger.debug(f"Notifications disabled - skipping price change alert for: {product_name}")
return True
webhook_urls = get_webhooks(site=site, alert_type="price_change")
if not webhook_urls or webhook_urls[0] == "YOUR_WEBHOOK_URL_HERE":
logger.error("Discord webhook URL not configured! Update config.py")
return False
# Determine if price went up or down
try:
old_val = float(old_price.replace("$", "").replace(",", "").strip()) if old_price else 0
new_val = float(new_price.replace("$", "").replace(",", "").strip()) if new_price else 0
price_dropped = new_val < old_val
except (ValueError, AttributeError):
price_dropped = False
# Use green for price drop, purple for price increase
color = 0x00FF00 if price_dropped else COLOR_PRICE_CHANGE
if price_dropped:
title = "\U0001F4B0 PRICE DROP!"
price_change_text = f"~~{old_price}~~ → **{new_price}**"
else:
title = "\U0001F4C8 Price Changed"
price_change_text = f"{old_price}{new_price}"
site_emoji = SITE_EMOJIS.get(site.lower(), "\U0001F6D2")
site_display = site.replace("pokemoncenter", "Pokemon Center").title()
stock_status = "\u2705 In Stock" if in_stock else "\u274C Out of Stock"
embed = {
"title": title,
"description": f"**{product_name}**",
"url": product_url,
"color": color,
"fields": [
{"name": "Price Change", "value": price_change_text, "inline": True},
{"name": "Store", "value": f"{site_emoji} {site_display}", "inline": True},
{"name": "Stock Status", "value": stock_status, "inline": True},
{"name": "Link", "value": f"[\U0001F6D2 View Product]({product_url})", "inline": False},
],
"footer": {"text": "Pokemon Stock Monitor"},
"timestamp": datetime.utcnow().isoformat(),
}
if image_url:
embed["thumbnail"] = {"url": image_url}
# Only ping everyone for price drops on in-stock items
content = "@everyone" if price_dropped and in_stock else None
payload = {"embeds": [embed]}
if content:
payload["content"] = content
success = False
for webhook_url in webhook_urls:
try:
response = requests.post(webhook_url, json=payload, timeout=10)
response.raise_for_status()
success = True
except requests.exceptions.RequestException as e:
logger.error(f"Failed to send price change notification: {e}")
if success:
logger.info(f"Price change notification sent for: {product_name} ({old_price} -> {new_price}) to {len(webhook_urls)} channel(s)")
return success
def send_startup_notification(): def send_startup_notification():
"""Send a notification that the monitor has started""" """Send a notification that the monitor has started"""