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:
- URL Queue (Producer): Manages target URLs with concurrency limits and deduplication.
- Browser Pool (Worker): Spawns headless chromium instances via Playwright async context managers.
- Data Extractor: Extracts structured metadata, dynamic DOM elements, and network metrics.
- 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)
+--------------------+
🛠️ Prerequisites & Setup
Ensure you have Python 3.9+ installed. Install the required dependencies:
pip install playwright httpx pydantic
playwright install chromium
🐍 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))
⚡ Key Technical Highlights
-
Non-blocking I/O: Leverages Python
asyncioloop with Playwright's native asynchronous driver to handle high concurrency without blocking CPU cycles. - Headless Execution & Anti-Detection: Uses custom User-Agents and browser contexts to render dynamic JavaScript SPA content cleanly.
-
Real-time Webhook Streaming: Uses
httpx.AsyncClientto fire non-blocking JSON payloads instantly as pages complete scraping, enabling downstream message brokers (e.g. Kafka, RabbitMQ) to consume crawl data without delay. -
Queue-driven Scaling: Scalable worker pool managing concurrency and deduplication via
asyncio.Queueand 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"}insidebrowser.new_context(). -
Resource Cleanup: Always use
try/finallyblocks 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)