Async web crawler with politeness and caching
asyncio + httpx, robots.txt, a SQLite disk cache, and rich progress output — an asyncio mastery capstone.
Goal
Build a high-performance, well-behaved web crawler in pure asyncio: respects robots.txt, rate-limits per host, caches responses, handles retries / redirects, extracts structured data via CSS / XPath, and persists to SQLite or Postgres.
Why this project
Demonstrates mastery of asyncio: backpressure, structured concurrency (TaskGroup), cancellation, shared state, retries. Web scraping is in constant demand and a great showcase.
User experience
spidey crawl https://news.ycombinator.com \
--depth 2 \
--concurrency 50 \
--rate 5 \
--out hn.db
spidey export hn.db --format jsonl > hn.jsonl
spidey stats hn.db
# pages_total=1240, pages_per_minute=180, errors=12, avg_latency=380msTech stack
- httpx (async client, HTTP/2, connection pooling).
- selectolax (very fast HTML parser) or lxml.
- aiosqlite or asyncpg for storage.
- tenacity (retries with jitter).
- rich (progress bars + table summary).
- typer (CLI).
- structlog (structured logs).
- pytest + respx for mocking HTTP.
- uv + Docker.
Architecture
src/spidey/
├── __init__.py
├── cli.py
├── crawler.py # main orchestration (TaskGroup)
├── fetcher.py # httpx wrapper with retries
├── parser.py # HTML -> links + extracted data
├── frontier.py # priority queue of URLs
├── robots.py # robots.txt cache + check
├── politeness.py # per-host rate limiter
├── dedupe.py # URL canonicalisation + seen-set (bloom filter)
├── cache.py # HTTP cache (ETag, If-Modified-Since)
├── storage/
│ ├── sqlite.py
│ └── postgres.py
├── extractors/
│ ├── base.py
│ ├── meta.py # OG tags, title, description
│ └── article.py # readability-like text extraction
└── config.py
tests/
├── conftest.py
├── test_robots.py
├── test_politeness.py
├── test_dedupe.py
├── test_fetcher.py
└── test_integration.pySpec
Core loop (pseudo)
async def crawl(seeds: list[str], *, depth: int, concurrency: int):
frontier = Frontier(seeds)
async with httpx.AsyncClient(http2=True, follow_redirects=True) as client, \
asyncio.TaskGroup() as tg:
for _ in range(concurrency):
tg.create_task(worker(client, frontier, depth))Worker
async def worker(client, frontier, depth):
while True:
url, current_depth = await frontier.next()
if url is None:
break
if current_depth > depth:
continue
try:
await politeness.wait(host_of(url))
if not await robots.allowed(url):
continue
r = await fetcher.fetch(client, url)
page = await parser.parse(url, r.text)
await storage.save(page)
if current_depth < depth:
for link in page.links:
if dedupe.is_new(link):
await frontier.put(link, current_depth + 1)
except Exception as e:
log.exception("worker", url=url, err=str(e))Frontier
- Priority queue (asyncio.Queue) keyed by depth + heuristic score.
- Persistent: writes to SQLite so crawls resume after crash.
Politeness
Semaphoreper host (default 2 concurrent requests/host).- Sliding-window rate limit per host (default 1 req/sec).
- Honour
Crawl-delayfrom robots.txt. - Global rate limit too (overall concurrency).
Robots.txt
- Fetch + parse once per host (24h cache).
- Use
urllib.robotparserorprotego.
Dedup
- Canonicalise URLs: lower-case host, sort query params, drop
utm_*. - Bloom filter for in-memory seen-set (millions of URLs in MB).
- SQLite
seen_urlstable for persistence.
Cache
- Honour ETag / Last-Modified.
- 304 → use cached body.
- Store body compressed (gzip) in SQLite blob.
Retries
- 4xx (except 408/429): no retry.
- 429: respect
Retry-After, then exponential backoff. - 5xx: exponential backoff with jitter, max 3.
- Connection errors: backoff + retry.
Extractors
- Pluggable; extract structured data per page type.
- Default: title, meta description, canonical URL, lang, links, body text (readability heuristic).
Acceptance criteria
- Crawls 10,000 pages in < 5 min on home internet (politeness-permitting).
- Memory < 500 MB for that workload.
- Resumes correctly after Ctrl+C / crash.
- Respects robots.txt + crawl-delay; politeness honoured.
- Zero duplicate pages stored.
- Tests: 30+ tests including respx-mocked integration.
- mypy --strict.
Stretch goals
- Distributed mode: workers on multiple machines, frontier in Redis.
- JS rendering for SPA sites (Playwright integration).
- Sitemap.xml parsing for seeding.
- Headless screenshot per page.
- Full-text search index (sqlite FTS5 / Tantivy).
- Web UI (FastAPI + HTMX) to monitor crawls and browse data.
- Smart politeness (back off when latency rises).
- AI extraction: pass HTML to LLM for structured JSON fields.
- Domain whitelist/blacklist.
Key implementation hints
Per-host rate limiter
class HostLimiter:
def __init__(self, rate: float):
self._rate = rate
self._last: dict[str, float] = {}
self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
async def wait(self, host: str):
async with self._locks[host]:
delta = time.monotonic() - self._last.get(host, 0)
wait = max(0, 1 / self._rate - delta)
if wait:
await asyncio.sleep(wait)
self._last[host] = time.monotonic()Bloom-filter dedupe
from pybloom_live import ScalableBloomFilter
class Dedupe:
def __init__(self):
self.bloom = ScalableBloomFilter(initial_capacity=1_000_000, error_rate=0.0001)
def is_new(self, url: str) -> bool:
canon = canonicalise(url)
if canon in self.bloom:
return False
self.bloom.add(canon)
return TrueFetcher with retries
from tenacity import retry, retry_if_exception_type, wait_exponential_jitter, stop_after_attempt
@retry(
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
wait=wait_exponential_jitter(initial=1, max=30),
stop=stop_after_attempt(3),
reraise=True,
)
async def fetch(client: httpx.AsyncClient, url: str) -> httpx.Response:
r = await client.get(url, timeout=15)
if r.status_code == 429:
retry_after = float(r.headers.get("Retry-After", "5"))
await asyncio.sleep(retry_after)
raise httpx.HTTPError("429")
r.raise_for_status()
return rParser with selectolax
from selectolax.parser import HTMLParser
def parse(url: str, html: str) -> Page:
tree = HTMLParser(html)
title = tree.css_first("title")
links = [a.attributes.get("href") for a in tree.css("a[href]")]
abs_links = [urljoin(url, l) for l in links if l]
text = tree.body.text(separator="\n", strip=True) if tree.body else ""
return Page(url=url, title=title.text() if title else "", links=abs_links, text=text)TaskGroup for graceful shutdown
try:
async with asyncio.TaskGroup() as tg:
for _ in range(concurrency):
tg.create_task(worker(...))
except* KeyboardInterrupt:
print("Saving state and shutting down...")
await frontier.persist()Deliverables
- GitHub repo with sample crawls (HN, your blog, etc.).
- Benchmarks vs
scrapy(crawler/sec, RAM, latency). - Architecture diagram of components.
- 10-min screencast: "Crawling Hacker News in 2 minutes."
Lessons exercised
- 03_advanced (generators, decorators, context managers)
- 04_stdlib_and_modern (asyncio especially)
- 05_data (storage)
- 06_testing (respx, hypothesis)
- 07_performance (profiling async)
- 08_web_and_apis (httpx)
Time estimate: 20–30 hours core; 50–80 hours with JS rendering + distributed mode + UI.
Wrapping up the project track
After finishing all five, you have:
- A typed, tested, packaged CLI on PyPI (Project 1).
- A polished developer tool with plugins and live reload (Project 2).
- A production FastAPI service with full ops (Project 3).
- A data engineering framework (Project 4).
- A high-performance async crawler (Project 5).
This portfolio answers: "Show me you can ship Python." Pair each project with a blog post or screencast and you're hireable at senior Python engineer / backend / data engineer level.
Total time across all five (core specs only): 100–150 hours. With all stretch goals: 300–400 hours. Pace yourself — 1 project per 4–6 weeks is realistic alongside a full-time job.