Skip to content

Web Scraping

Scraping more than a handful of pages means solving several problems at once: you need concurrency (sequential fetches are too slow), rate limiting (too fast and you get IP-banned), retry (pages fail randomly with 503s and timeouts), and error tracking (you need to know which URLs failed so you can retry them). With raw ThreadPoolExecutor you end up hand-rolling all of this every time.

import httpx
from bs4 import BeautifulSoup
from pyarallel import parallel_map, RateLimit, Retry

def scrape_page(url):
    r = httpx.get(url, timeout=10, follow_redirects=True)
    r.raise_for_status()
    soup = BeautifulSoup(r.text, "html.parser")
    return {
        "url": url,
        "title": soup.title.string.strip() if soup.title else None,
        "links": [a["href"] for a in soup.find_all("a", href=True)],
    }

urls = [...]  # hundreds or thousands of URLs

result = parallel_map(
    scrape_page, urls,
    workers=10,
    rate_limit=RateLimit(5, "second"),     # 5 req/s — polite enough to avoid bans
    retry=Retry(attempts=3, backoff=2.0, on=(httpx.ConnectError, httpx.ReadTimeout)),
    timeout=300.0,                          # 5 min wall-clock limit for the whole job
)

What pyarallel handles for you here:

  • Rate limiting prevents IP bans — the token bucket spreads requests evenly instead of bursting. RateLimit(5, "second") means one request every 200ms, not 5 requests in the first 10ms then silence.
  • Retry with backoff handles transient 503s and connection resets without you writing try/except loops inside the worker.
  • Timeout kills the entire operation if a site is unresponsive, instead of hanging forever on one slow page.
  • Structured errors tell you exactly which URLs failed and why:
if not result.ok:
    for idx, exc in result.failures():
        print(f"Failed: {urls[idx]}{type(exc).__name__}: {exc}")

    # Retry just the failures with more patience
    failed_urls = [urls[idx] for idx, _ in result.failures()]
    retry_result = parallel_map(
        scrape_page, failed_urls,
        workers=3,
        rate_limit=RateLimit(1, "second"),
        retry=Retry(attempts=5, backoff=5.0),
    )

For large crawls (10K+ URLs), use streaming so results don't accumulate in memory:

from pyarallel import parallel_iter

for item in parallel_iter(scrape_page, url_generator(), workers=10,
                          rate_limit=RateLimit(5, "second"), window_size=500):
    if item.ok:
        save_to_db(item.value)
    else:
        log_failed_url(item.index, item.error)