DEV Community

Cover image for Building a Resilient Web Scraping & Alert Pipeline in Python
Sruthi sri G
Sruthi sri G

Posted on

Building a Resilient Web Scraping & Alert Pipeline in Python

Building a Resilient Web Scraping & Alert Pipeline in Python

Most web scraping tutorials show you how to fetch a static page with requests and print a price to the terminal. While fine for toy demos, that approach quickly falls apart in production: servers throttle requests, network connections drop, and website layouts change unexpectedly.

In this tutorial, we will build a production-ready, asynchronous price-monitoring pipeline in Python.

By the end of this guide, you will have a pipeline that:

  1. Fetches multiple target URLs concurrently using aiohttp.
  2. Parses product data reliably with BeautifulSoup4.
  3. Handles retries with exponential backoff.
  4. Dispatches formatted alert notifications to a Discord or Slack webhook when target thresholds are hit.

Architecture Overview

Here is how data flows through our pipeline:

[Target URLs] 
      │
      ▼
[Async HTTP Fetcher] ──(Backoff / Retries)──► [Network Resilience]
      │
      ▼
[Parser & Validator] ──(Extracts price & title safely)
      │
      ▼
[Threshold Engine] ───(Compares current vs. desired price)
      │
      ▼
[Discord Webhook Alert]
Enter fullscreen mode Exit fullscreen mode

Prerequisites

Before starting, ensure you have:

  • Python 3.9+ installed.
  • A basic understanding of asynchronous Python (async/await).
  • A Discord channel with an active Webhook URL (or a dummy URL for testing).

Create and activate a virtual environment, then install the required dependencies:

python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate
pip install aiohttp beautifulsoup4 pydantic
Enter fullscreen mode Exit fullscreen mode

Step 1: Define the Data Models with Pydantic

Strong typing prevents silent failures when parsing scraped HTML. Let's create models.py to define our data structures.

# models.py
from pydantic import BaseModel, HttpUrl, Field
from typing import Optional

class TargetProduct(BaseModel):
    name: str
    url: HttpUrl
    target_price: float = Field(gt=0, description="Alert if price drops below this value")

class ScrapedResult(BaseModel):
    name: str
    url: str
    current_price: Optional[float] = None
    in_stock: bool = False
    error: Optional[str] = None
Enter fullscreen mode Exit fullscreen mode

Using Pydantic guarantees that invalid target configurations fail loudly at startup rather than mid-execution.


Step 2: Build the Asynchronous Fetcher with Retries

Scrapers frequently encounter temporary rate limits (429 Too Many Requests) or connection timeouts. Instead of failing immediately, we implement exponential backoff.

Create fetcher.py:

# fetcher.py
import asyncio
import logging
import aiohttp
from typing import Optional

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

DEFAULT_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/124.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "en-US,en;q=0.9",
}

async def fetch_html_with_retry(
    session: aiohttp.ClientSession,
    url: str,
    max_retries: int = 3,
    base_delay: float = 1.5,
) -> Optional[str]:
    """
    Fetches raw HTML from a URL with exponential backoff on transient errors.
    """
    for attempt in range(1, max_retries + 1):
        try:
            async with session.get(url, headers=DEFAULT_HEADERS, timeout=aiohttp.ClientTimeout(total=10)) as response:
                if response.status == 200:
                    return await response.text()
                elif response.status in (429, 500, 502, 503, 504):
                    delay = base_delay * (2 ** (attempt - 1))
                    logging.warning(f"HTTP {response.status} for {url}. Retrying in {delay:.1f}s (Attempt {attempt}/{max_retries})...")
                    await asyncio.sleep(delay)
                else:
                    logging.error(f"Unrecoverable HTTP {response.status} for {url}")
                    return None
        except (aiohttp.ClientError, asyncio.TimeoutError) as err:
            delay = base_delay * (2 ** (attempt - 1))
            logging.warning(f"Network error on {url}: {err}. Retrying in {delay:.1f}s...")
            await asyncio.sleep(delay)

    logging.error(f"Exceeded max retries for {url}")
    return None
Enter fullscreen mode Exit fullscreen mode

Why this matters:

  • ClientTimeout(total=10): Prevents requests from hanging indefinitely.
  • Exponential backoff (2 ** (attempt - 1)): Gives the target server breathing room instead of slamming it with immediate retries.
  • Custom User-Agent: Emulates a standard browser header to avoid naive bot blockers.

Step 3: Implement Resilient HTML Parsing

Websites change selectors frequently. We isolate our extraction logic inside parser.py and sanitize price strings into clean floating-point numbers.

# parser.py
import re
from typing import Optional
from bs4 import BeautifulSoup
from models import TargetProduct, ScrapedResult

def parse_product_html(html: str, target: TargetProduct) -> ScrapedResult:
    """
    Parses product details safely from HTML using multi-selector fallbacks.
    """
    soup = BeautifulSoup(html, "html.parser")

    # Common selector patterns for prices across modern storefronts
    price_selectors = [
        ".price", "#priceblock_ourprice", ".a-price-whole",
        "[data-testid='product-price']", ".current-price"
    ]

    extracted_price: Optional[float] = None

    for selector in price_selectors:
        element = soup.select_one(selector)
        if element and element.get_text(strip=True):
            raw_text = element.get_text(strip=True)
            # Match numbers like $129.99 or 1,299.00
            cleaned_match = re.search(r"(\d+[\d,]*\.?\d*)", raw_text.replace(",", ""))
            if cleaned_match:
                extracted_price = float(cleaned_match.group(1))
                break

    # Check for Out of Stock indicators
    stock_text = soup.get_text().lower()
    out_of_stock = any(phrase in stock_text for phrase in ["out of stock", "currently unavailable", "sold out"])

    return ScrapedResult(
        name=target.name,
        url=str(target.url),
        current_price=extracted_price,
        in_stock=not out_of_stock,
        error="Price not found" if extracted_price is None else None
    )
Enter fullscreen mode Exit fullscreen mode

Step 4: Dispatching Alerts via Discord Webhook

When a price drops below our target, we notify the user immediately via an embedded message.

Create notifier.py:

# notifier.py
import aiohttp
import logging
from models import ScrapedResult, TargetProduct

async def send_discord_alert(
    session: aiohttp.ClientSession,
    webhook_url: str,
    product: TargetProduct,
    result: ScrapedResult
) -> bool:
    """
    Sends an alert card to a Discord webhook channel.
    """
    payload = {
        "embeds": [
            {
                "title": f"🚨 Price Drop Alert: {product.name}",
                "url": str(product.url),
                "color": 3066993,  # Green
                "fields": [
                    {"name": "Current Price", "value": f"${result.current_price:.2f}", "inline": True},
                    {"name": "Target Price", "value": f"${product.target_price:.2f}", "inline": True},
                    {"name": "Status", "value": "In Stock" if result.in_stock else "Out of Stock", "inline": False},
                ],
                "footer": {"text": "Python Automation Pipeline"},
            }
        ]
    }

    try:
        async with session.post(webhook_url, json=payload) as resp:
            return resp.status in (200, 204)
    except Exception as exc:
        logging.error(f"Failed to deliver webhook alert: {exc}")
        return False
Enter fullscreen mode Exit fullscreen mode

Step 5: Putting It All Together

Create the orchestrator in main.py using asyncio.gather to scrape multiple targets concurrently:

# main.py
import asyncio
import os
import aiohttp
from models import TargetProduct
from fetcher import fetch_html_with_retry
from parser import parse_product_html
from notifier import send_discord_alert

# Replace with your actual Discord Webhook or an environment variable
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "https://discord.com/api/webhooks/YOUR_KEY")

TARGET_WATCHLIST = [
    TargetProduct(
        name="Mechanical Keyboard",
        url="https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",  # Safe sandbox url
        target_price=60.00
    ),
    TargetProduct(
        name="Noise Cancelling Headphones",
        url="https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html",
        target_price=55.00
    ),
]

async def monitor_target(session: aiohttp.ClientSession, target: TargetProduct):
    html = await fetch_html_with_retry(session, str(target.url))
    if not html:
        print(f"[-] Could not retrieve page for: {target.name}")
        return

    result = parse_product_html(html, target)
    print(f"[+] Scraped {result.name}: Current=${result.current_price} | In Stock={result.in_stock}")

    # Trigger alert if criteria is met
    if result.current_price and result.current_price <= target.target_price and result.in_stock:
        print(f"[!] Target hit for {target.name}! Sending alert...")
        await send_discord_alert(session, DISCORD_WEBHOOK_URL, target, result)

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [monitor_target(session, target) for target in TARGET_WATCHLIST]
        await asyncio.gather(*tasks)

if __name__ == "__main__":
    asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Production Best Practices

When deploying this pipeline as a scheduled job (e.g., via GitHub Actions or a cron daemon):

  1. Rotate IP Addresses: For large-scale scrapers (hundreds of URLs), route requests through residential proxies.
  2. Respect robots.txt: Always check the site's scraping policies and avoid hitting internal endpoints too aggressively.
  3. Persist State: Store previous prices in SQLite or Redis to calculate historical price trends.

Summary

In this guide, you built a production-grade scraping pipeline that avoids the common pitfalls of naive scraper scripts. You learned how to:

  • Enforce strict typing with Pydantic.
  • Mitigate transient network and rate-limit drops with exponential backoff.
  • Parse unstructured HTML defensively.
  • Push real-time asynchronous notifications to chat applications.

You can now expand this pipeline into a full side-project or monetization engine by offering automated competitor tracking to e-commerce merchants.

Top comments (0)