DEV Community

Building Real-Time Asynchronous Web Crawlers with Python Asyncio, Playwright, and Webhooks

Building Real-Time Asynchronous Web Crawlers with Python Asyncio, Playwright, and Webhooks

Web scraping has evolved from fetching static HTML using requests and BeautifulSoup to handling dynamic JavaScript-rendered Single Page Applications (SPAs). Modern web platforms rely heavily on client-side rendering, WebSocket connections, and complex DOM manipulations.

In this comprehensive tutorial, we will build a production-ready, asynchronous web crawler using Python's asyncio, Microsoft's Playwright (async API), and an automated Webhook dispatch pipeline to stream data in real-time.


🏗️ Architecture Overview

Our crawler follows an asynchronous Producer-Consumer design pattern:

  1. URL Queue (Producer): Manages target URLs with concurrency limits and deduplication.
  2. Browser Pool (Worker): Spawns headless chromium instances via Playwright async context managers.
  3. Data Extractor: Extracts structured metadata, dynamic DOM elements, and network metrics.
  4. Webhook Dispatcher (Consumer): Posts JSON payloads asynchronously to a designated webhook endpoint as soon as a page is scraped.
       +--------------------+
       |   Target URLs      |
       +---------+----------+
                 |
                 v
       +--------------------+
       |   asyncio.Queue    |
       +---------+----------+
                 |
        +--------+--------+
        |                 |
        v                 v
 +--------------+  +--------------+
 | Worker 1     |  | Worker 2     |  (Playwright Async Pages)
 +------+-------+  +------+-------+
        |                 |
        +--------+--------+
                 |
                 v
       +--------------------+
       |  Webhook Dispatch  |  (HTTPX / Async POST)
       +--------------------+
Enter fullscreen mode Exit fullscreen mode

🛠️ Prerequisites & Setup

Ensure you have Python 3.9+ installed. Install the required dependencies:

pip install playwright httpx pydantic
playwright install chromium
Enter fullscreen mode Exit fullscreen mode

🐍 Complete Implementation Code (crawler.py)

Here is the complete, runnable asynchronous web crawler engine:

import asyncio
import logging
import time
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, HttpUrl
import httpx
from playwright.async_api import async_playwright, BrowserContext, Page

# Configure Logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.StreamHandler()]
)

# Models
class ScrapeTarget(BaseModel):
    url: str
    depth: int = 0
    max_depth: int = 1

class ScrapedPayload(BaseModel):
    url: str
    title: str
    status_code: int
    content_length: int
    scraped_at: float
    links: List[str]

class AsyncCrawlerEngine:
    def __init__(self, webhook_url: Optional[str] = None, concurrency: int = 3):
        self.webhook_url = webhook_url
        self.concurrency = concurrency
        self.queue: asyncio.Queue[ScrapeTarget] = asyncio.Queue()
        self.visited: set = set()
        self.http_client = httpx.AsyncClient(timeout=10.0)

    async def send_webhook(self, payload: ScrapedPayload):
        """Dispatches real-time scraped data via Webhook POST request."""
        if not self.webhook_url:
            logging.info(f"[Payload Processed] {payload.url} -> Title: '{payload.title}'")
            return

        try:
            response = await self.http_client.post(
                self.webhook_url,
                json=payload.model_dump()
            )
            logging.info(f"[Webhook Delivered] {payload.url} -> HTTP {response.status_code}")
        except Exception as e:
            logging.error(f"[Webhook Failed] {payload.url}: {e}")

    async def scrape_page(self, context: BrowserContext, target: ScrapeTarget):
        """Scrapes a single page using Playwright async page API."""
        page: Page = await context.new_page()
        try:
            logging.info(f"[Scraping] {target.url}")
            response = await page.goto(target.url, wait_until="domcontentloaded", timeout=30000)

            title = await page.title()
            content = await page.content()
            status = response.status if response else 200

            # Extract internal/external links
            href_elements = await page.query_selector_all("a[href]")
            links = []
            for elem in href_elements:
                href = await elem.get_attribute("href")
                if href and href.startswith("http"):
                    links.append(href)

            payload = ScrapedPayload(
                url=target.url,
                title=title,
                status_code=status,
                content_length=len(content),
                scraped_at=time.time(),
                links=links[:10]  # Cap top 10 links
            )

            await self.send_webhook(payload)

            # Enqueue child links if depth allows
            if target.depth < target.max_depth:
                for link in links[:5]:
                    if link not in self.visited:
                        self.visited.add(link)
                        await self.queue.put(ScrapeTarget(url=link, depth=target.depth + 1, max_depth=target.max_depth))

        except Exception as err:
            logging.error(f"[Error Scraping] {target.url}: {err}")
        finally:
            await page.close()

    async def worker(self, context: BrowserContext):
        """Worker task consuming URLs from the asyncio queue."""
        while True:
            try:
                target = await asyncio.wait_for(self.queue.get(), timeout=3.0)
                await self.scrape_page(context, target)
                self.queue.task_done()
            except asyncio.TimeoutError:
                break
            except Exception as e:
                logging.error(f"[Worker Exception] {e}")
                self.queue.task_done()

    async def run(self, seed_urls: List[str]):
        """Main engine loop establishing Playwright browser pool."""
        for url in seed_urls:
            self.visited.add(url)
            await self.queue.put(ScrapeTarget(url=url, depth=0, max_depth=1))

        async with async_playwright() as p:
            browser = await p.chromium.launch(headless=True)
            context = await browser.new_context(
                user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
            )

            workers = [asyncio.create_task(self.worker(context)) for _ in range(self.concurrency)]

            await self.queue.join()
            await asyncio.gather(*workers)

            await context.close()
            await browser.close()
            await self.http_client.aclose()

if __name__ == "__main__":
    seeds = [
        "https://news.ycombinator.com",
        "https://httpbin.org/html"
    ]
    # Replace with your webhook receiver URL (e.g. webhook.site)
    webhook_endpoint = "https://httpbin.org/post"

    engine = AsyncCrawlerEngine(webhook_url=webhook_endpoint, concurrency=2)
    asyncio.run(engine.run(seeds))
Enter fullscreen mode Exit fullscreen mode

⚡ Key Technical Highlights

  1. Non-blocking I/O: Leverages Python asyncio loop with Playwright's native asynchronous driver to handle high concurrency without blocking CPU cycles.
  2. Headless Execution & Anti-Detection: Uses custom User-Agents and browser contexts to render dynamic JavaScript SPA content cleanly.
  3. Real-time Webhook Streaming: Uses httpx.AsyncClient to fire non-blocking JSON payloads instantly as pages complete scraping, enabling downstream message brokers (e.g. Kafka, RabbitMQ) to consume crawl data without delay.
  4. Queue-driven Scaling: Scalable worker pool managing concurrency and deduplication via asyncio.Queue and thread-safe sets.

🛡️ Production Best Practices

  • Rate Limiting & Delays: Introduce random asyncio.sleep(random.uniform(1.0, 3.0)) between navigation requests to respect target servers.
  • Proxy Rotation: Pass proxy={"server": "http://user:pass@proxy_host:port"} inside browser.new_context().
  • Resource Cleanup: Always use try/finally blocks or async context managers to close browser pages, avoiding memory leaks.

🎯 Conclusion

By combining Python's asyncio, Playwright's headless browser control, and real-time Webhook streaming, you can build modern web scrapers capable of extracting dynamic JavaScript content at scale.

Top comments (0)