Async web crawler with politeness and caching

asyncio + httpx, robots.txt, a SQLite disk cache, and rich progress output — an asyncio mastery capstone.

🛠 Advanced

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

powershell
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=380ms

Tech 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.py

Spec

Core loop (pseudo)

python
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

python
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

  • Semaphore per host (default 2 concurrent requests/host).
  • Sliding-window rate limit per host (default 1 req/sec).
  • Honour Crawl-delay from robots.txt.
  • Global rate limit too (overall concurrency).

Robots.txt

  • Fetch + parse once per host (24h cache).
  • Use urllib.robotparser or protego.

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_urls table 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

  1. Crawls 10,000 pages in < 5 min on home internet (politeness-permitting).
  2. Memory < 500 MB for that workload.
  3. Resumes correctly after Ctrl+C / crash.
  4. Respects robots.txt + crawl-delay; politeness honoured.
  5. Zero duplicate pages stored.
  6. Tests: 30+ tests including respx-mocked integration.
  7. 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

python
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

python
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 True

Fetcher with retries

python
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 r

Parser with selectolax

python
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

python
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:

  1. A typed, tested, packaged CLI on PyPI (Project 1).
  2. A polished developer tool with plugins and live reload (Project 2).
  3. A production FastAPI service with full ops (Project 3).
  4. A data engineering framework (Project 4).
  5. 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.