Build a Restock Alert Monitor

Monitor Pokemon Center and Adidas pages and get instant push notifications.

Introduction

30 Second Summary

Limited-edition Pokemon cards and Adidas drops sell out in minutes. By the time you manually check the product page, it's already gone.

In this project, you will build a Python stock monitor that uses stealth browser automation and residential proxies to watch product pages on Pokemon Center and Adidas. The moment something comes back in stock, you get an instant push notification on your phone with a tap-to-buy link.

What You'll Build

An async Python script using Playwright with anti-fingerprinting stealth patches and rotating residential proxies to detect stock transitions and fire alerts via ntfy.sh (phone push) and Discord (rich webhook embeds).

By the end of this project, you'll have:

  • A stealth browser engine with proxy rotation and fingerprint randomization that bypasses anti-bot protections on Pokemon Center and Adidas.
  • A state-tracking system using SQLite that detects stock transitions and only alerts on actual changes.
  • Dual-channel instant alerts delivered to your iPhone via ntfy.sh (with tap-to-open product links) and to your Discord server as rich embeds with product name, price, and status.
  • Secret Mission: Deploy your monitor to a VPS with systemd for 24/7 unattended restock detection.

Are there any prerequisites?

This project assumes you have Python scripting experience and are comfortable running commands in a terminal. You also need a Discord server where you have permission to create webhooks, and an iPhone with the ntfy app installed for push notifications.

Create the Configuration File

Your dependencies are installed and you have all your service credentials ready. Now it's time to tell your monitor where to connect and what to watch.

A centralized configuration file keeps all connection details, timing parameters, and product URLs in one place. This means you can change what you monitor or adjust alert timing without digging through multiple scripts.

In this step, get ready to:
  • Create a configuration file with proxy, notification, and webhook credentials.
  • Define monitoring intervals and the product URLs to watch for restocks.
Set up service connection settings

Your monitor needs to reach three external services: Webshare for residential proxies, ntfy.sh for phone push notifications, and Discord for webhook alerts.

  • Create a new file called config.py in your project folder and add the following code:
import os

# Proxy configuration (Webshare rotating residential)
PROXY_HOST = "p.webshare.io"
PROXY_PORT = "80"
PROXY_USER = os.environ.get("WEBSHARE_USER", "your-webshare-username")
PROXY_PASS = os.environ.get("WEBSHARE_PASS", "your-webshare-password")

# ntfy.sh configuration
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "your-private-topic-name")
NTFY_URL = f"https://ntfy.sh/{NTFY_TOPIC}"

# Discord webhook
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK", "your-discord-webhook-url")

What does this code do?

Each credential is loaded from an environment variable using os.environ.get(). The second argument is a fallback placeholder in case the environment variable isn't set yet.

  • PROXY_HOST and PROXY_PORT point to Webshare's rotating residential proxy gateway.
  • NTFY_URL builds the full notification endpoint from your private topic name.
  • DISCORD_WEBHOOK_URL is the webhook endpoint you created in your Discord server channel.

Why environment variables instead of hardcoded values?

Storing secrets in environment variables keeps them out of your code files. If you ever share this project or push it to GitHub, your credentials stay private.

The fallback placeholders let you run the script during development without setting up environment variables first. You'll replace them with real values before your first live test.

You won't see any output yet. The next section completes the file with product definitions.

Add monitoring parameters and product URLs

The monitor needs to know how often to check pages and which products to watch. Timing parameters control poll frequency and alert cooldowns to prevent notification spam.

  • In config.py, add the following code below the DISCORD_WEBHOOK_URL line:
# Monitoring settings
CHECK_INTERVAL_SECONDS = 180  # 3 minutes between checks
ALERT_COOLDOWN_SECONDS = 300  # 5 minutes between duplicate alerts
DATABASE_PATH = "stock_state.db"

# Products to monitor
PRODUCTS = [
    {
        "url": "https://www.pokemoncenter.com/product/example-etb",
        "name": "Pokemon ETB Example",
        "site": "pokemoncenter",
    },
    {
        "url": "https://www.adidas.com/us/example-product.html",
        "name": "Adidas Product Example",
        "site": "adidas",
    },
]

What does this code do?

  • CHECK_INTERVAL_SECONDS sets a 3-minute gap between each monitoring cycle. This avoids hammering the sites too aggressively.
  • ALERT_COOLDOWN_SECONDS prevents duplicate alerts for the same product within 5 minutes.
  • DATABASE_PATH names the SQLite file that will track stock states between checks.
  • PRODUCTS is a list of dictionaries. Each entry has a url to check, a name for alert messages, and a site key that tells the monitor which parser to use.

Use real product URLs

Replace the example URLs with actual product pages you want to monitor. Find a Pokemon Center ETB or Booster Box page that shows "Out of Stock" or "Notify Me" and paste its URL into the first entry.

For Adidas, navigate to a product page showing "Sold Out" and grab that URL for the second entry. You can add more products by copying the dictionary format.

  • Save config.py.
  • Verify the config file loads correctly by running this command in your terminal:
python -c "import config; print(f'Monitoring {len(config.PRODUCTS)} products via {config.PROXY_HOST}')"

You should see: Monitoring 2 products via p.webshare.io.

Seeing a ModuleNotFoundError or SyntaxError?

  • Make sure you're running the command from your project folder where config.py lives.
  • Check for mismatched quotes or missing commas in the PRODUCTS list. Each dictionary entry needs a trailing comma after the closing brace.
  • Confirm your f-string on the NTFY_URL line uses curly braces around NTFY_TOPIC and the string is prefixed with f.

Still stuck? Help me debug my config.py import error

✔️ Awesome, I've got everything!

Great. Double check you've saved config.py before moving on.

ⓧ I'd like to double check the full code

import os

# Proxy configuration (Webshare rotating residential)
PROXY_HOST = "p.webshare.io"
PROXY_PORT = "80"
PROXY_USER = os.environ.get("WEBSHARE_USER", "your-webshare-username")
PROXY_PASS = os.environ.get("WEBSHARE_PASS", "your-webshare-password")

# ntfy.sh configuration
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "your-private-topic-name")
NTFY_URL = f"https://ntfy.sh/{NTFY_TOPIC}"

# Discord webhook
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK", "your-discord-webhook-url")

# Monitoring settings
CHECK_INTERVAL_SECONDS = 180  # 3 minutes between checks
ALERT_COOLDOWN_SECONDS = 300  # 5 minutes between duplicate alerts
DATABASE_PATH = "stock_state.db"

# Products to monitor
PRODUCTS = [
    {
        "url": "https://www.pokemoncenter.com/product/example-etb",
        "name": "Pokemon ETB Example",
        "site": "pokemoncenter",
    },
    {
        "url": "https://www.adidas.com/us/example-product.html",
        "name": "Adidas Product Example",
        "site": "adidas",
    },
]

Your configuration is locked in. Next, you'll build the stealth browser engine that uses these proxy settings to visit product pages without getting blocked.

Build the Stealth Browser Engine

Sites like Pokemon Center and Adidas deploy aggressive anti-bot systems. These systems fingerprint your browser by inspecting properties like navigator.webdriver, Canvas rendering, WebGL vendor strings, and screen resolution.

In this step, you will build a stealth browser engine that rotates user-agent strings and viewport sizes, routes traffic through Webshare residential proxies, and blocks heavy resources to save bandwidth. The result is a browser context that looks like a real person browsing from a residential IP.

In this step, get ready to:
  • Create fingerprint randomization pools for rotating browser identities.
  • Build an async stealth browser function with proxy rotation and resource blocking.
  • Add a page fetcher utility that mimics human browsing timing.
Set up stealth imports and fingerprint pools

Anti-bot systems flag browsers that always present the same fingerprint. To counter this, you will define pools of real-world user-agent strings and viewport dimensions that the browser engine picks from randomly on each launch.

  • Create a new file called browser.py in your project folder.
  • Add the imports and fingerprint randomization pools by pasting this code:
import asyncio
import random
from playwright.async_api import async_playwright, BrowserContext
from pw_stealth_enhanced import apply_stealth, StealthConfig
from config import PROXY_HOST, PROXY_PORT, PROXY_USER, PROXY_PASS

USER_AGENTS = [
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
]

VIEWPORTS = [
    {"width": 1920, "height": 1080},
    {"width": 1366, "height": 768},
    {"width": 1440, "height": 900},
    {"width": 1536, "height": 864},
]

What does this code do?

The imports bring in Playwright's async API for browser automation, pw-stealth-enhanced for anti-detection patches, and the proxy credentials from your config.py.

USER_AGENTS contains three real browser strings (two Chrome variants and one Safari). VIEWPORTS holds four common screen resolutions. Each time the engine launches, it picks one of each at random so the browser looks different to anti-bot systems.

  • Save browser.py.

Seeing an import error for pw_stealth_enhanced?

Make sure you installed the dependencies in Step 1. Run pip install pw-stealth-enhanced again if needed.

help me fix a pw-stealth-enhanced import error in my browser.py file

Create the stealth browser context with proxy rotation

The core of the stealth engine is a function that launches Chromium with anti-detection flags, routes traffic through your Webshare residential proxy, applies pw-stealth-enhanced fingerprint patches, and blocks heavy resources to conserve proxy bandwidth.

  • Add the create_stealth_context function below the VIEWPORTS list in browser.py:

async def create_stealth_context(playwright) -> tuple:
    proxy_url = f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}"
    viewport = random.choice(VIEWPORTS)
    user_agent = random.choice(USER_AGENTS)

    browser = await playwright.chromium.launch(
        headless=True,
        args=[
            "--disable-blink-features=AutomationControlled",
            "--disable-dev-shm-usage",
            "--no-sandbox",
        ],
    )

    context = await browser.new_context(
        proxy={"server": proxy_url},
        viewport=viewport,
        user_agent=user_agent,
        locale="en-US",
        timezone_id="America/New_York",
    )

    stealth_config = StealthConfig(
        rotate_ua=False,
        locale="en-US",
        timezone_id="America/New_York",
    )
    await apply_stealth(context, config=stealth_config)

    # Block heavy resources to save bandwidth
    await context.route(
        "**/*.{png,jpg,jpeg,gif,svg,webp,woff,woff2,ttf,css}",
        lambda route: route.abort(),
    )

    return browser, context

What does this function do?

  • Builds the proxy URL from your Webshare credentials and picks a random viewport and user-agent from the pools.
  • Launches headless Chromium with --disable-blink-features=AutomationControlled to hide the navigator.webdriver flag that bots normally expose.
  • Creates a browser context that routes all traffic through the residential proxy, with locale and timezone set to America/New_York to match typical US traffic.
  • Applies apply_stealth which injects Canvas noise, WebGL spoofing, audio fingerprint perturbation, and font enumeration spoofing into the browser context.
  • Blocks images, CSS, fonts, and other heavy resources via context.route. This reduces page weight by roughly 90%, letting you make far more checks per GB of proxy bandwidth.

Why set rotate_ua to False?

You are managing UA rotation yourself via the USER_AGENTS pool and random.choice. Setting rotate_ua=False tells pw-stealth-enhanced not to override your chosen UA with its own internal pool. This keeps the UA consistent between what Playwright sends and what the stealth patches report to JavaScript.

  • Save browser.py.

Seeing a proxy connection error?

Double-check that the WEBSHARE_USER and WEBSHARE_PASS environment variables are set correctly. The proxy gateway p.webshare.io on port 80 requires valid credentials or it will reject the connection.

help me debug a Webshare proxy connection error in Playwright

Add the page fetcher function

The parsers you will build in the next step need a simple way to load a product page through the stealth context. This utility function opens a page, waits a random human-like delay, grabs the HTML content, and closes the page.

  • Add the fetch_page function at the bottom of browser.py:

async def fetch_page(context: BrowserContext, url: str, timeout: int = 30000) -> str:
    page = await context.new_page()
    try:
        await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
        await page.wait_for_timeout(random.randint(2000, 5000))
        content = await page.content()
        return content
    finally:
        await page.close()

What does this function do?

  • Opens a new page tab in the stealth context and navigates to the given URL.
  • Waits for domcontentloaded (the DOM is ready) rather than load (all resources finished). This is faster since you are blocking images and CSS anyway.
  • Adds a random 2 to 5 second delay to mimic a human reading the page. Consistent instant requests are a bot signal.
  • Returns the raw HTML as a string, then closes the page tab in the finally block to prevent memory leaks even if an error occurs.
  • Save browser.py.
  • Confirm the file exists by running:
ls browser.py

You should see browser.py listed in the output.

File not showing up?

Make sure you saved the file inside your project folder. Run pwd to confirm you are in the correct directory.

help me find where my browser.py file was saved

✔️ Awesome, I've got everything!

Great. Double check you have saved browser.py and you are ready for the next step.

ⓧ I'd like to double check the full code

Here is the complete browser.py file for comparison:

import asyncio
import random
from playwright.async_api import async_playwright, BrowserContext
from pw_stealth_enhanced import apply_stealth, StealthConfig
from config import PROXY_HOST, PROXY_PORT, PROXY_USER, PROXY_PASS

USER_AGENTS = [
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
]

VIEWPORTS = [
    {"width": 1920, "height": 1080},
    {"width": 1366, "height": 768},
    {"width": 1440, "height": 900},
    {"width": 1536, "height": 864},
]


async def create_stealth_context(playwright) -> tuple:
    proxy_url = f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}"
    viewport = random.choice(VIEWPORTS)
    user_agent = random.choice(USER_AGENTS)

    browser = await playwright.chromium.launch(
        headless=True,
        args=[
            "--disable-blink-features=AutomationControlled",
            "--disable-dev-shm-usage",
            "--no-sandbox",
        ],
    )

    context = await browser.new_context(
        proxy={"server": proxy_url},
        viewport=viewport,
        user_agent=user_agent,
        locale="en-US",
        timezone_id="America/New_York",
    )

    stealth_config = StealthConfig(
        rotate_ua=False,
        locale="en-US",
        timezone_id="America/New_York",
    )
    await apply_stealth(context, config=stealth_config)

    # Block heavy resources to save bandwidth
    await context.route(
        "**/*.{png,jpg,jpeg,gif,svg,webp,woff,woff2,ttf,css}",
        lambda route: route.abort(),
    )

    return browser, context


async def fetch_page(context: BrowserContext, url: str, timeout: int = 30000) -> str:
    page = await context.new_page()
    try:
        await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
        await page.wait_for_timeout(random.randint(2000, 5000))
        content = await page.content()
        return content
    finally:
        await page.close()

Your stealth browser engine is ready. Next up, you will build site-specific parsers that use this engine to check whether Pokemon Center and Adidas products are in stock or sold out.

Build Site-Specific Parsers

Your stealth browser engine is ready to load pages without getting flagged. Now you need to teach it what to look for on each page.

Different e-commerce sites display stock status in different ways. Pokemon Center shows "Add to Cart" or "Out of Stock" labels. Adidas uses "Add to Bag" or "Sold Out" indicators. In this step, you'll build dedicated parsers for each site that know exactly which text patterns signal a product is available.

In this step, get ready to:
  • Write a Pokemon Center parser that detects "Add to Cart" vs "Out of Stock" states.
  • Write an Adidas parser that detects "Add to Bag" vs "Sold Out" states.
  • Add error handling for timeouts, WAF blocks, and network failures with structured logging.
Write the Pokemon Center parser

Pokemon Center product pages show an "Add to Cart" button when an item is in stock. When it sells out, the button changes to "Out of Stock" or "Notify Me". Your parser needs to load the page, scan the HTML for these keywords, and return a clean status.

  • Create a new file called parsers.py in your project folder.
  • Add the following code to set up imports, logging, and the Pokemon Center parser:
import re
from playwright.async_api import BrowserContext, Page
import logging

logger = logging.getLogger(__name__)


async def check_pokemoncenter(context: BrowserContext, url: str) -> dict:
    page = await context.new_page()
    try:
        await page.goto(url, wait_until="domcontentloaded", timeout=30000)
        await page.wait_for_timeout(3000)

        # Detect stock status from buy button area
        content = await page.content()
        text_lower = content.lower()

        if "add to cart" in text_lower:
            status = "in_stock"
        elif "out of stock" in text_lower or "notify me" in text_lower or "sold out" in text_lower:
            status = "out_of_stock"
        else:
            status = "unknown"
            logger.warning(f"Could not determine stock status for {url}")

        # Try to extract price
        price = None
        price_match = re.search(r'\$[\d]+\.[\d]{2}', content)
        if price_match:
            price = price_match.group(0)

        return {"status": status, "price": price}
    except Exception as e:
        logger.error(f"Error checking Pokemon Center {url}: {e}")
        return {"status": "error", "price": None}
    finally:
        await page.close()

What does this code do?

The parser opens a new browser page, navigates to the product URL, and waits 3 seconds for dynamic content to render.

  • text_lower = content.lower() converts the entire page HTML to lowercase so the keyword search is case-insensitive.
  • The if/elif/else chain checks for "add to cart" (in stock), "out of stock"/"notify me"/"sold out" (out of stock), or returns "unknown" if none match.
  • The regex r'\$[\d]+\.[\d]{2}' extracts a dollar price like $49.99 from the page content.
  • The try/except/finally block ensures the page closes even if an error occurs, and logs any failures for debugging.

Why search the full HTML instead of targeting a specific element?

Pokemon Center uses Imperva WAF and frequently changes its page structure. Targeting a specific CSS selector would break every time they update their layout. Searching the full page text for keywords like "add to cart" is more resilient to layout changes.

  • Save parsers.py.

You won't see output yet. The next chunk adds the Adidas parser so both sites are covered.

Write the Adidas parser

Adidas uses different button text than Pokemon Center. Their "Add to Bag" button signals availability, while "Sold Out" or "Unavailable" means it's gone. Adidas also uses Akamai Bot Manager for anti-bot protection, making stealth browsing essential.

  • In parsers.py, add the Adidas parser function and the site dispatch dictionary below the check_pokemoncenter function:

async def check_adidas(context: BrowserContext, url: str) -> dict:
    page = await context.new_page()
    try:
        await page.goto(url, wait_until="domcontentloaded", timeout=30000)
        await page.wait_for_timeout(3000)

        content = await page.content()
        text_lower = content.lower()

        if "add to bag" in text_lower or "add to cart" in text_lower:
            status = "in_stock"
        elif "sold out" in text_lower or "unavailable" in text_lower or "notify me" in text_lower:
            status = "out_of_stock"
        else:
            status = "unknown"
            logger.warning(f"Could not determine stock status for {url}")

        price = None
        price_match = re.search(r'\$[\d]+', content)
        if price_match:
            price = price_match.group(0)

        return {"status": status, "price": price}
    except Exception as e:
        logger.error(f"Error checking Adidas {url}: {e}")
        return {"status": "error", "price": None}
    finally:
        await page.close()


SITE_PARSERS = {
    "pokemoncenter": check_pokemoncenter,
    "adidas": check_adidas,
}

What does this code do?

  • check_adidas follows the same pattern as the Pokemon Center parser but looks for "add to bag" (Adidas's button label) in addition to "add to cart".
  • The price regex r'\$[\d]+' is simpler here because Adidas often displays whole-dollar prices without cents.
  • SITE_PARSERS is a dispatch dictionary that maps each site name (from your config.py product entries) to the correct parser function. This lets the monitor loop call the right parser automatically.
  • Save parsers.py.
  • Confirm the file has no syntax errors by running:
python3 -c "import parsers; print(list(parsers.SITE_PARSERS.keys()))"

You should see ['pokemoncenter', 'adidas'] printed in your terminal.

Seeing a ModuleNotFoundError?

  • Make sure you're running the command from the same directory where parsers.py and config.py live.
  • Check that playwright is installed in your active environment. Run pip list | grep playwright to verify.
  • Help me debug this import error in parsers.py
Understand the error handling for WAF blocks and network failures

Both parsers include defensive code for three failure modes that happen constantly when scraping protected sites. These sites will block your requests, time out, or serve captcha pages instead of product data.

How does the error handling work?

  • Timeouts: The timeout=30000 parameter on page.goto() raises a Playwright timeout error if the page doesn't load within 30 seconds. The except Exception block catches this, logs the error, and returns status: "error" so the monitor can skip this product and retry next cycle.
  • WAF blocks (captcha/access denied): When a WAF like Imperva or Akamai serves a captcha page instead of the real product page, none of the stock keywords ("add to cart", "sold out") will be found. The parser returns status: "unknown" and logs a warning. The monitor treats "unknown" as a no-op, so no false alerts fire.
  • Network failures: DNS resolution errors, proxy connection drops, and socket timeouts all raise exceptions that the except block catches. The finally block guarantees the browser page is closed regardless of what went wrong, preventing memory leaks.

Why structured logging instead of print statements?

The logging module gives you severity levels (logger.info, logger.warning, logger.error) that can be filtered and routed to files. When your monitor runs 24/7, you need to tell the difference between a captcha warning (recoverable) and a crash (needs intervention). Print statements all look the same in a log file.

✔️ Awesome, I've got everything!

Great. Double check you've saved parsers.py and that the import test printed both site names.

ⓧ I'd like to double check the full code

Here is the complete parsers.py file:

import re
from playwright.async_api import BrowserContext, Page
import logging

logger = logging.getLogger(__name__)


async def check_pokemoncenter(context: BrowserContext, url: str) -> dict:
    page = await context.new_page()
    try:
        await page.goto(url, wait_until="domcontentloaded", timeout=30000)
        await page.wait_for_timeout(3000)

        # Detect stock status from buy button area
        content = await page.content()
        text_lower = content.lower()

        if "add to cart" in text_lower:
            status = "in_stock"
        elif "out of stock" in text_lower or "notify me" in text_lower or "sold out" in text_lower:
            status = "out_of_stock"
        else:
            status = "unknown"
            logger.warning(f"Could not determine stock status for {url}")

        # Try to extract price
        price = None
        price_match = re.search(r'\$[\d]+\.[\d]{2}', content)
        if price_match:
            price = price_match.group(0)

        return {"status": status, "price": price}
    except Exception as e:
        logger.error(f"Error checking Pokemon Center {url}: {e}")
        return {"status": "error", "price": None}
    finally:
        await page.close()


async def check_adidas(context: BrowserContext, url: str) -> dict:
    page = await context.new_page()
    try:
        await page.goto(url, wait_until="domcontentloaded", timeout=30000)
        await page.wait_for_timeout(3000)

        content = await page.content()
        text_lower = content.lower()

        if "add to bag" in text_lower or "add to cart" in text_lower:
            status = "in_stock"
        elif "sold out" in text_lower or "unavailable" in text_lower or "notify me" in text_lower:
            status = "out_of_stock"
        else:
            status = "unknown"
            logger.warning(f"Could not determine stock status for {url}")

        price = None
        price_match = re.search(r'\$[\d]+', content)
        if price_match:
            price = price_match.group(0)

        return {"status": status, "price": price}
    except Exception as e:
        logger.error(f"Error checking Adidas {url}: {e}")
        return {"status": "error", "price": None}
    finally:
        await page.close()


SITE_PARSERS = {
    "pokemoncenter": check_pokemoncenter,
    "adidas": check_adidas,
}

Your parsers can now read stock status from both Pokemon Center and Adidas. Next up, you'll build the state tracking system that remembers what was in stock last time, so the monitor only alerts you when something actually changes.

Build State Tracking and Transition Detection

Your parsers can now read stock status from Pokemon Center and Adidas product pages. The problem is that checking stock every 3 minutes without memory means you'd get spammed with alerts even when nothing changes.

You need a persistent memory layer that remembers each product's last known state. That way, the monitor only fires an alert when stock actually flips from unavailable to available.

In this step, get ready to:
  • Create a SQLite database that persists product stock states across monitor cycles.
  • Build state query and upsert functions that enable transition detection.
  • Add alert cooldown tracking to prevent duplicate notifications.
Create the SQLite database schema

SQLite is a file-based database that needs no server. It stores your product states in a single .db file that persists between runs of the monitor.

  • Create a new file called state.py in your project folder.
  • Add the imports and database initialization function by pasting this code:
import aiosqlite
from datetime import datetime
from config import DATABASE_PATH


async def init_db():
    async with aiosqlite.connect(DATABASE_PATH) as db:
        await db.execute("""
            CREATE TABLE IF NOT EXISTS product_state (
                url TEXT PRIMARY KEY,
                name TEXT,
                site TEXT,
                last_status TEXT,
                last_price TEXT,
                last_checked TEXT,
                last_alerted TEXT
            )
        """)
        await db.commit()

What does this code do?

aiosqlite is an async wrapper for SQLite. It lets the monitor interact with the database without blocking other async tasks.

init_db() creates the product_state table if it doesn't already exist. The url column is the primary key, meaning each product URL gets exactly one row that tracks its latest status, price, and timestamps.

  • Save state.py.
  • Test the database creation by running this command:
python -c "import asyncio; from state import init_db; asyncio.run(init_db())"
  • Confirm the database file was created by running:
ls stock_state.db

You should see stock_state.db listed in the output.

Seeing a ModuleNotFoundError?

  • If you see No module named 'aiosqlite', make sure you installed all dependencies from requirements.txt in the earlier step.
  • If you see No module named 'config', make sure you're running the command from inside your project folder where config.py lives.

Why is my database not being created?

Build the state read and write functions

The monitor needs two operations for transition detection: reading the last known stock status, and writing the new status after each check.

  • In state.py, add the get_last_status() function below the init_db() function:
async def get_last_status(url: str) -> str | None:
    async with aiosqlite.connect(DATABASE_PATH) as db:
        cursor = await db.execute(
            "SELECT last_status FROM product_state WHERE url = ?", (url,)
        )
        row = await cursor.fetchone()
        return row[0] if row else None

What does this code do?

This function looks up a product URL in the database and returns its last known stock status as a string (like "in_stock" or "out_of_stock").

If the URL has never been checked before (first run), it returns None. This is the baseline handling: when the previous status is None, the monitor stores the initial state without firing an alert.

You won't see a change yet. The next function uses this one to detect transitions.

  • Add the update_status() function below get_last_status():
async def update_status(url: str, name: str, site: str, status: str, price: str | None):
    now = datetime.now().isoformat()
    async with aiosqlite.connect(DATABASE_PATH) as db:
        await db.execute("""
            INSERT INTO product_state (url, name, site, last_status, last_price, last_checked)
            VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT(url) DO UPDATE SET
                last_status = excluded.last_status,
                last_price = excluded.last_price,
                last_checked = excluded.last_checked
        """, (url, name, site, status, price, now))
        await db.commit()

What does this code do?

This function performs an upsert (insert or update). On the first check of a product, it inserts a new row. On subsequent checks, it updates the existing row with the latest status, price, and timestamp.

The ON CONFLICT(url) DO UPDATE clause is the key. Since url is the primary key, SQLite detects a conflict if the URL already exists and runs the update instead of failing.

  • Save state.py.
  • Test the read/write cycle by running this command:
python -c "import asyncio; from state import init_db, update_status, get_last_status; asyncio.run(init_db()); asyncio.run(update_status('https://test.com', 'Test', 'test', 'out_of_stock', None)); print(asyncio.run(get_last_status('https://test.com')))"

You should see out_of_stock printed in the terminal, confirming the write-then-read cycle works.

Seeing None instead of out_of_stock?

  • Make sure the init_db() call comes before update_status() in the test command. The table must exist before you can write to it.
  • Check that the ON CONFLICT clause matches the code above exactly. A typo in the SQL will silently fail.

Help me debug my state.py read/write functions.

Add alert cooldown tracking

Even with transition detection, network glitches can cause rapid in-stock/out-of-stock flapping. The cooldown functions record when an alert was last sent so duplicate notifications get suppressed within a configurable window.

  • In state.py, add the get_last_alerted() function below update_status():
async def get_last_alerted(url: str) -> str | None:
    async with aiosqlite.connect(DATABASE_PATH) as db:
        cursor = await db.execute(
            "SELECT last_alerted FROM product_state WHERE url = ?", (url,)
        )
        row = await cursor.fetchone()
        return row[0] if row else None

What does this code do?

This returns the ISO timestamp of when an alert was last sent for a given product URL. The monitor will compare this against ALERT_COOLDOWN_SECONDS (set to 300 seconds in your config) to decide whether to suppress a duplicate alert.

You won't see a change yet. The next function completes the cooldown pair.

  • Add the set_last_alerted() function below get_last_alerted():
async def set_last_alerted(url: str):
    now = datetime.now().isoformat()
    async with aiosqlite.connect(DATABASE_PATH) as db:
        await db.execute(
            "UPDATE product_state SET last_alerted = ? WHERE url = ?", (now, url)
        )
        await db.commit()

What does this code do?

This stamps the current time on a product's row after an alert is sent. On the next check cycle, the monitor reads this timestamp and skips sending another alert if the cooldown period hasn't elapsed yet.

  • Save state.py.

You won't see a visible change from these two functions yet. They'll be exercised by the monitoring loop in the next step.

How does transition detection actually work?

Here's the full flow the monitor will follow in the next step using the functions you just built:

  • get_last_status(url) retrieves what the product was last time.
  • The parser checks the live page and returns the current status.
  • If previous was out_of_stock and current is in_stock, fire an alert. Otherwise, stay silent.
  • update_status() saves the new state for next cycle's comparison.

On first run, get_last_status() returns None. Since None is not "out_of_stock", no alert fires. The initial state gets stored quietly.

✔️ Awesome, I've got everything!

Great. Double check you've saved state.py and confirm the earlier test printed out_of_stock in your terminal.

ⓧ I'd like to double check the full code

import aiosqlite
from datetime import datetime
from config import DATABASE_PATH


async def init_db():
    async with aiosqlite.connect(DATABASE_PATH) as db:
        await db.execute("""
            CREATE TABLE IF NOT EXISTS product_state (
                url TEXT PRIMARY KEY,
                name TEXT,
                site TEXT,
                last_status TEXT,
                last_price TEXT,
                last_checked TEXT,
                last_alerted TEXT
            )
        """)
        await db.commit()


async def get_last_status(url: str) -> str | None:
    async with aiosqlite.connect(DATABASE_PATH) as db:
        cursor = await db.execute(
            "SELECT last_status FROM product_state WHERE url = ?", (url,)
        )
        row = await cursor.fetchone()
        return row[0] if row else None


async def update_status(url: str, name: str, site: str, status: str, price: str | None):
    now = datetime.now().isoformat()
    async with aiosqlite.connect(DATABASE_PATH) as db:
        await db.execute("""
            INSERT INTO product_state (url, name, site, last_status, last_price, last_checked)
            VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT(url) DO UPDATE SET
                last_status = excluded.last_status,
                last_price = excluded.last_price,
                last_checked = excluded.last_checked
        """, (url, name, site, status, price, now))
        await db.commit()


async def get_last_alerted(url: str) -> str | None:
    async with aiosqlite.connect(DATABASE_PATH) as db:
        cursor = await db.execute(
            "SELECT last_alerted FROM product_state WHERE url = ?", (url,)
        )
        row = await cursor.fetchone()
        return row[0] if row else None


async def set_last_alerted(url: str):
    now = datetime.now().isoformat()
    async with aiosqlite.connect(DATABASE_PATH) as db:
        await db.execute(
            "UPDATE product_state SET last_alerted = ? WHERE url = ?", (now, url)
        )
        await db.commit()

Your state layer is solid. Next up, you'll build the alert system that fires push notifications to your phone and rich embeds to Discord when a restock transition is detected.

Build the Alert System

Your monitor can now detect stock changes and remember them across runs. The missing piece is the alert delivery. When a product flips from out-of-stock to in-stock, you need to know immediately.

In this step, you will build a dual-channel notification system. One channel sends a push notification straight to your iPhone via ntfy.sh with a tap-to-open link. The other sends a rich embed to your Discord server with product details, price, and a direct purchase link.

In this step, get ready to:
  • Build an ntfy.sh push notification function that opens the product page when tapped.
  • Build a Discord webhook function that sends rich embeds with product details.
  • Wire both alert channels into a single restock alert function.
Build the ntfy.sh notification function

ntfy.sh works by sending a simple HTTP POST to a topic URL. The headers control how the notification looks on your phone. The Click header is the key feature here. It sets the URL that opens when you tap the notification.

  • Create a new file called alerts.py in your project folder.
  • Add the imports, logger, and the send_ntfy_alert function by pasting this code:
import requests
from datetime import datetime
from discord_webhook import DiscordWebhook, DiscordEmbed
from config import NTFY_URL, DISCORD_WEBHOOK_URL
import logging

logger = logging.getLogger(__name__)


def send_ntfy_alert(product_name: str, product_url: str, site: str, price: str | None):
    message = f"{product_name} is BACK IN STOCK on {site}!"
    if price:
        message += f" Price: {price}"

    try:
        requests.post(
            NTFY_URL,
            data=message.encode("utf-8"),
            headers={
                "Title": f"RESTOCK: {product_name}",
                "Priority": "urgent",
                "Tags": "rotating_light,moneybag",
                "Click": product_url,
            },
        )
        logger.info(f"ntfy alert sent for {product_name}")
    except Exception as e:
        logger.error(f"Failed to send ntfy alert: {e}")

What does this code do?

The imports bring in everything you need for both alert channels. You import them all now so the file is ready for the Discord function next.

  • Title sets the notification headline your phone displays ("RESTOCK: Pokemon ETB Example").
  • Priority set to urgent triggers an immediate, high-priority alert that bypasses Do Not Disturb on most configurations.
  • Tags accepts emoji short codes. rotating_light and moneybag become visual emoji icons on the notification.
  • Click is the magic header. When you tap the notification on iOS, it opens product_url directly in Safari so you can buy immediately.
  • Save alerts.py.

You will not see output yet. The next function adds Discord support to the same file.

Build the Discord webhook function

Discord webhooks accept rich embeds that display product information in a structured card format. The discord-webhook library handles serialization and delivery.

  • In alerts.py, add the send_discord_alert function below send_ntfy_alert:
def send_discord_alert(product_name: str, product_url: str, site: str, price: str | None):
    try:
        webhook = DiscordWebhook(url=DISCORD_WEBHOOK_URL)
        embed = DiscordEmbed(
            title=f"RESTOCK: {product_name}",
            description=f"**{product_name}** is back in stock!",
            color="03b2f8",
            url=product_url,
        )
        embed.add_embed_field(name="Site", value=site.capitalize(), inline=True)
        if price:
            embed.add_embed_field(name="Price", value=price, inline=True)
        embed.add_embed_field(name="Status", value="IN STOCK", inline=True)
        embed.set_timestamp(datetime.now().timestamp())
        embed.set_footer(text="Restock Monitor")
        webhook.add_embed(embed)
        webhook.execute()
        logger.info(f"Discord alert sent for {product_name}")
    except Exception as e:
        logger.error(f"Failed to send Discord alert: {e}")

What does this code do?

  • DiscordEmbed creates a structured card with a clickable title that links directly to the product page via url=product_url.
  • add_embed_field adds inline fields (Site, Price, Status) that display side-by-side in the Discord message.
  • set_timestamp accepts a float (Unix timestamp), so you pass datetime.now().timestamp() to show the exact alert time in the embed footer.
  • The try/except block ensures a failed Discord delivery does not crash the entire monitor. The error gets logged and the next check cycle continues normally.
  • Save alerts.py.

You will not see output yet. The final function ties both channels together.

Wire alerts into a single restock function

Instead of calling both alert functions separately every time a restock is detected, a wrapper function fires both channels with a single call. This keeps the monitor logic clean.

  • In alerts.py, add the send_restock_alert function at the bottom of the file:
def send_restock_alert(product_name: str, product_url: str, site: str, price: str | None):
    send_ntfy_alert(product_name, product_url, site, price)
    send_discord_alert(product_name, product_url, site, price)

What does this code do?

send_restock_alert is the single entry point your monitor will call when a stock transition is detected. It fires ntfy.sh first (phone notification), then Discord (server embed). Each channel has its own error handling, so a failure in one does not block the other.

The cooldown logic (preventing duplicate alerts within ALERT_COOLDOWN_SECONDS) will live in the monitor loop you build in the next step, not in this function. This keeps alert delivery separate from alert scheduling.

  • Save alerts.py.
  • Verify the module loads correctly by running this command:
python3 -c "from alerts import send_restock_alert; print('alerts module loaded successfully')"

You should see alerts module loaded successfully printed in your terminal.

Seeing an import error?

  • If you see ModuleNotFoundError: No module named 'discord_webhook', make sure you installed your dependencies with pip install -r requirements.txt in an earlier step.
  • If you see a syntax error, check that alerts.py has all three function definitions with correct indentation. Compare against the full code below.

Still stuck? Help me debug my alerts.py import error

✔️ Awesome, I've got everything!

Great. Double check you have saved alerts.py and confirmed the module loads without errors.

ⓧ I'd like to double check the full code

import requests
from datetime import datetime
from discord_webhook import DiscordWebhook, DiscordEmbed
from config import NTFY_URL, DISCORD_WEBHOOK_URL
import logging

logger = logging.getLogger(__name__)


def send_ntfy_alert(product_name: str, product_url: str, site: str, price: str | None):
    message = f"{product_name} is BACK IN STOCK on {site}!"
    if price:
        message += f" Price: {price}"

    try:
        requests.post(
            NTFY_URL,
            data=message.encode("utf-8"),
            headers={
                "Title": f"RESTOCK: {product_name}",
                "Priority": "urgent",
                "Tags": "rotating_light,moneybag",
                "Click": product_url,
            },
        )
        logger.info(f"ntfy alert sent for {product_name}")
    except Exception as e:
        logger.error(f"Failed to send ntfy alert: {e}")


def send_discord_alert(product_name: str, product_url: str, site: str, price: str | None):
    try:
        webhook = DiscordWebhook(url=DISCORD_WEBHOOK_URL)
        embed = DiscordEmbed(
            title=f"RESTOCK: {product_name}",
            description=f"**{product_name}** is back in stock!",
            color="03b2f8",
            url=product_url,
        )
        embed.add_embed_field(name="Site", value=site.capitalize(), inline=True)
        if price:
            embed.add_embed_field(name="Price", value=price, inline=True)
        embed.add_embed_field(name="Status", value="IN STOCK", inline=True)
        embed.set_timestamp(datetime.now().timestamp())
        embed.set_footer(text="Restock Monitor")
        webhook.add_embed(embed)
        webhook.execute()
        logger.info(f"Discord alert sent for {product_name}")
    except Exception as e:
        logger.error(f"Failed to send Discord alert: {e}")


def send_restock_alert(product_name: str, product_url: str, site: str, price: str | None):
    send_ntfy_alert(product_name, product_url, site, price)
    send_discord_alert(product_name, product_url, site, price)

Your alert system is ready to fire on both channels. Next up, you will build the main monitor loop that ties the browser engine, parsers, state tracking, and alerts together into a single continuously running script.

Wire It All Together with the Monitor Script

You have a stealth browser engine, site-specific parsers, a state database, and a dual-channel alert system. Each module works independently, but nothing is orchestrating them into a continuous monitoring loop.

In this step, you will create the main monitor.py script that ties all the modules together. It will spin up the stealth browser, check each product on a loop, detect stock transitions, fire alerts only when something actually restocks, and log everything to both the terminal and a file.

In this step, get ready to:
  • Build the product-checking function with transition detection and cooldown logic.
  • Build the main monitoring loop that cycles through all products on a timer.
  • Run the monitor and confirm it logs stock checks to your terminal and a log file.
Set up imports and logging

A long-running monitor needs structured logs so you can diagnose problems without watching the terminal 24/7. Python's built-in logging module can write to both the console and a file simultaneously.

  • Create a new file called monitor.py in your project folder.
  • Add the imports and logging configuration by pasting this code:
import asyncio
import logging
from datetime import datetime, timedelta
from playwright.async_api import async_playwright
from browser import create_stealth_context
from parsers import SITE_PARSERS
from state import init_db, get_last_status, update_status, get_last_alerted, set_last_alerted
from alerts import send_restock_alert
from config import PRODUCTS, CHECK_INTERVAL_SECONDS, ALERT_COOLDOWN_SECONDS

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler("monitor.log"),
    ],
)
logger = logging.getLogger(__name__)

What does this code do?

The imports pull in every module you have built so far: the stealth browser from browser.py, the site parsers from parsers.py, the state functions from state.py, and the alert sender from alerts.py.

logging.basicConfig sets up dual output. StreamHandler() prints to your terminal so you can watch in real time. FileHandler("monitor.log") writes the same logs to a file for later review.

  • Save monitor.py.
Build the product-checking function

Each product in your config needs to be checked individually. The check_product function is responsible for calling the right parser, comparing the result against the stored state, and deciding whether to fire an alert.

  • In monitor.py, add the check_product function below the logger line by pasting this code:


async def check_product(context, product: dict):
    url = product["url"]
    name = product["name"]
    site = product["site"]

    parser = SITE_PARSERS.get(site)
    if not parser:
        logger.error(f"No parser for site: {site}")
        return

    result = await parser(context, url)
    current_status = result["status"]
    price = result["price"]

    if current_status == "error" or current_status == "unknown":
        logger.warning(f"Skipping state update for {name}: status={current_status}")
        return

    previous_status = await get_last_status(url)
    await update_status(url, name, site, current_status, price)

    logger.info(f"[{site}] {name}: {current_status} (was: {previous_status})")

    # Transition detection: out_of_stock -> in_stock
    if previous_status == "out_of_stock" and current_status == "in_stock":
        # Check cooldown
        last_alerted = await get_last_alerted(url)
        if last_alerted:
            last_alerted_time = datetime.fromisoformat(last_alerted)
            if datetime.now() - last_alerted_time < timedelta(seconds=ALERT_COOLDOWN_SECONDS):
                logger.info(f"Alert cooldown active for {name}, skipping")
                return

        logger.info(f"RESTOCK DETECTED: {name}")
        send_restock_alert(name, url, site, price)
        await set_last_alerted(url)

What does this code do?

  • SITE_PARSERS.get(site) looks up the correct parser function for the product's site (Pokemon Center or Adidas). If no parser exists, it logs an error and skips.
  • If the parser returns "error" or "unknown", the function skips the state update entirely. This prevents a failed scrape from overwriting good data in your database.
  • The transition detection only fires when previous_status was "out_of_stock" and the current check returns "in_stock". This means first-run checks (where previous status is None) quietly store the baseline without alerting.
  • The cooldown check prevents duplicate alerts if a product flickers in and out of stock rapidly. It compares the last alert time against ALERT_COOLDOWN_SECONDS (300 seconds by default).
  • Save monitor.py.

Seeing a red underline on the imports?

If your editor shows import errors for browser, parsers, state, or alerts, make sure all your .py files are in the same folder. The imports reference sibling files directly.

help me fix import errors in my monitor script

Build the monitoring loop and run the script

The run_monitor function is the infinite loop that keeps your monitor alive. It initializes the database, spins up a fresh stealth browser on each cycle, checks every product, then sleeps until the next round.

  • In monitor.py, add the run_monitor function and main block below check_product by pasting this code:


async def run_monitor():
    await init_db()
    logger.info("Stock monitor started")
    logger.info(f"Monitoring {len(PRODUCTS)} products every {CHECK_INTERVAL_SECONDS}s")

    while True:
        async with async_playwright() as playwright:
            try:
                browser, context = await create_stealth_context(playwright)
                for product in PRODUCTS:
                    await check_product(context, product)
                    # Random delay between products
                    await asyncio.sleep(2)
                await context.close()
                await browser.close()
            except Exception as e:
                logger.error(f"Error in monitoring loop: {e}")

        logger.info(f"Sleeping {CHECK_INTERVAL_SECONDS}s until next check")
        await asyncio.sleep(CHECK_INTERVAL_SECONDS)


if __name__ == "__main__":
    asyncio.run(run_monitor())

What does this code do?

  • init_db() creates the SQLite database and table on first run. On subsequent runs it does nothing because of the CREATE TABLE IF NOT EXISTS clause.
  • The while True loop keeps the monitor running indefinitely. Each iteration spawns a fresh browser context, checks all products, then tears down the browser before sleeping.
  • A 2-second asyncio.sleep(2) between products adds a human-like gap between requests. This reduces the chance of rate-limiting or bot detection.
  • The try/except block catches any crash during a cycle without killing the entire monitor. The loop simply logs the error and tries again next round.
  • if __name__ == "__main__" ensures the monitor only starts when you run the file directly, not when it gets imported.

Why create a new browser each cycle?

Reusing the same browser session for hours makes your fingerprint increasingly recognizable to anti-bot systems. Tearing down and recreating the context each cycle gives you a fresh fingerprint (new viewport, user agent, and proxy rotation) every 3 minutes.

  • Save monitor.py.
  • Start the monitor by running this command in your terminal:
python monitor.py
  • Watch the terminal output. You should see timestamped log lines showing the monitor starting, followed by status checks for each product in your PRODUCTS list.
  • Press Ctrl+C to stop the monitor after you see the first check complete.
  • Confirm the log file was created by running:
ls monitor.log
  • You should see monitor.log listed in the output. This file contains the same log entries you saw in the terminal.

Monitor crashes immediately?

  • If you see a proxy connection error, double-check that your WEBSHARE_USER and WEBSHARE_PASS environment variables are set correctly, or that the fallback values in config.py contain your real credentials.
  • If you see a browser launch error, make sure you ran playwright install chromium during setup.
  • If the monitor runs but logs "unknown" status for every product, the target pages may be serving a captcha or block page. Check the log for warnings and try again in a few minutes.

help me debug my stock monitor

✔️ Awesome, I've got everything!

Great. Double check you have saved monitor.py and that monitor.log exists in your project folder.

ⓧ I'd like to double check the full code

import asyncio
import logging
from datetime import datetime, timedelta
from playwright.async_api import async_playwright
from browser import create_stealth_context
from parsers import SITE_PARSERS
from state import init_db, get_last_status, update_status, get_last_alerted, set_last_alerted
from alerts import send_restock_alert
from config import PRODUCTS, CHECK_INTERVAL_SECONDS, ALERT_COOLDOWN_SECONDS

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler("monitor.log"),
    ],
)
logger = logging.getLogger(__name__)


async def check_product(context, product: dict):
    url = product["url"]
    name = product["name"]
    site = product["site"]

    parser = SITE_PARSERS.get(site)
    if not parser:
        logger.error(f"No parser for site: {site}")
        return

    result = await parser(context, url)
    current_status = result["status"]
    price = result["price"]

    if current_status == "error" or current_status == "unknown":
        logger.warning(f"Skipping state update for {name}: status={current_status}")
        return

    previous_status = await get_last_status(url)
    await update_status(url, name, site, current_status, price)

    logger.info(f"[{site}] {name}: {current_status} (was: {previous_status})")

    # Transition detection: out_of_stock -> in_stock
    if previous_status == "out_of_stock" and current_status == "in_stock":
        # Check cooldown
        last_alerted = await get_last_alerted(url)
        if last_alerted:
            last_alerted_time = datetime.fromisoformat(last_alerted)
            if datetime.now() - last_alerted_time < timedelta(seconds=ALERT_COOLDOWN_SECONDS):
                logger.info(f"Alert cooldown active for {name}, skipping")
                return

        logger.info(f"RESTOCK DETECTED: {name}")
        send_restock_alert(name, url, site, price)
        await set_last_alerted(url)


async def run_monitor():
    await init_db()
    logger.info("Stock monitor started")
    logger.info(f"Monitoring {len(PRODUCTS)} products every {CHECK_INTERVAL_SECONDS}s")

    while True:
        async with async_playwright() as playwright:
            try:
                browser, context = await create_stealth_context(playwright)
                for product in PRODUCTS:
                    await check_product(context, product)
                    # Random delay between products
                    await asyncio.sleep(2)
                await context.close()
                await browser.close()
            except Exception as e:
                logger.error(f"Error in monitoring loop: {e}")

        logger.info(f"Sleeping {CHECK_INTERVAL_SECONDS}s until next check")
        await asyncio.sleep(CHECK_INTERVAL_SECONDS)


if __name__ == "__main__":
    asyncio.run(run_monitor())

Your restock monitor is fully operational. It checks your target products every 3 minutes, detects the exact moment something comes back in stock, and pings your phone and Discord instantly. Next up, you will take this further in the Secret Mission by deploying it to a VPS for 24/7 unattended monitoring.

Secret mission

Deploy to a VPS with systemd

Your monitor works locally, but it stops the moment you close your laptop. In this secret mission, you'll deploy the entire stack to a cloud VPS running 24/7, managed by systemd with automatic crash recovery and a dead man's switch that alerts you if the monitor itself goes down.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses a paid Webshare proxy subscription (~$3.50/month) and optionally a VPS (~$4-6/month if you completed the Secret Mission). The ntfy.sh topic and Discord webhook are free.

Cost warning

Your Webshare residential proxy subscription renews automatically every 30 days at ~$3.50/month. If you deployed to a VPS, that also incurs ongoing charges (~$4-6/month). If you are not actively using the monitor, cancel or pause these to avoid unexpected charges.

Resources you used:

  • Webshare residential proxy subscription (~$3.50/month).
  • VPS droplet (~$4-6/month, Secret Mission only).
  • ntfy.sh topic (free).
  • Discord webhook (free).
  • Local project files on your machine.

Keep everything running

No action needed. Choose this if you are still actively using the restock monitor and want to keep receiving alerts.

  • Your Webshare subscription will continue to renew monthly.
  • If you completed the Secret Mission, your VPS will continue running the monitor as a systemd service.
  • Your ntfy.sh topic and Discord webhook remain active at no cost.

Pause - I'll come back to this later

Stop running processes to free up resources and reduce costs, but keep your files and accounts for later.

  • If running on your VPS, SSH in and stop the systemd service:
  • Disable auto-renewal on your Webshare subscription to keep proxies until the current billing cycle ends without being charged again. Go to your Webshare Profile Settings, find the Auto-renewal row, and click Disable Auto-Renewal.
  • Your ntfy.sh topic, Discord webhook, and local files can stay as-is at no cost.
sudo systemctl stop restock-monitor
sudo systemctl disable restock-monitor

Why disable instead of delete?

Stopping the service halts the monitor immediately. Disabling it prevents the service from starting again on reboot. Your project files, database, and configuration remain on the VPS so you can re-enable the service later.

Delete - I don't want to use this again

Remove all project resources and subscriptions permanently.

Cancel your Webshare subscription:

  • Log in to your Webshare dashboard and go to Account Settings.
  • Scroll down to the Your Subscription section and click Cancel Subscription.
  • Select the residential proxy plan and click Continue, then confirm the cancellation. This switches your account to the Free Plan immediately.

Destroy your VPS (Secret Mission only):

  • If you used DigitalOcean, open the Control Panel, click your Droplet's name, go to the Settings tab, and click Destroy. Type the Droplet's name in the confirmation field and click Destroy.
  • If you used a different VPS provider, find the equivalent delete or terminate action in their control panel.

Remove your ntfy.sh topic:

  • Open the ntfy app on your iPhone, swipe left on the topic you subscribed to, and tap Delete.

Delete your Discord webhook:

  • In Discord, open your server's channel settings, go to Integrations, find the webhook you created, and click Delete Webhook.

Delete your local project files:

cd ~/Desktop
rm -rf restock-monitor

On Windows, use:

cd ~\Desktop
Remove-Item -Recurse -Force restock-monitor

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade stealth stock monitor that watches Pokemon Center and Adidas for restocks and pings your phone the instant something comes back in stock.

You've learned how to:

  • Built an async stealth browser engine with Playwright, anti-fingerprinting patches via pw-stealth-enhanced, and residential proxy rotation through Webshare.
  • Created site-specific parsers for Pokemon Center and Adidas with SQLite-backed state tracking that only fires alerts on actual stock transitions.
  • Set up dual-channel instant alerting with ntfy.sh push notifications (tap to open the product page) and Discord rich embeds.
  • Secret Mission: Deployed to a VPS with systemd for 24/7 unattended monitoring and a dead man's switch heartbeat.

Ready to quiz yourself?