Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 076 / 100

Python for web scraping, responsibly: a practical guide

Collect data without breaking sites or rules.

Expona Admin8 min read

Python for web scraping, responsibly: a practical guide

Web scraping with Python can feel like a superpower—gather market data, monitor competitor pricing, or collect research samples at scale. But that power comes with responsibility: scrape badly and you'll get IP-banned, face legal trouble, or take down someone's server. This guide walks through the practices and tools we use on real projects to collect data ethically and sustainably.

Understanding robots.txt and terms of service

Before you write a single line of scraping code, open a browser and check two places. First, visit https://example.com/robots.txt to see what the site explicitly permits. Second, read the terms of service. These aren't just legal cover—they tell you whether the site owner wants scrapers at all.

A robots.txt file might look like this:

python
User-agent: *
Disallow: /admin/
Disallow: /api/private/
Allow: /products/
Crawl-delay: 2

That Crawl-delay: 2 is a signal. It means "wait 2 seconds between requests." Respect it. The Disallow rules tell crawlers which paths are off-limits. If you scrape /admin/, you're ignoring explicit instruction.

In our experience, the sites that get scrapped hardest are the ones nobody asked permission from. Reach out to the site owner or API team first. Many will grant access directly—and you'll get cleaner, faster data through an official feed than by scraping HTML.

For Python, parse robots.txt with the built-in urllib.robotparser:

python
from urllib.robotparser import RobotFileParser

rp = RobotFileParser()
rp.set_url("https://example.com/robots.txt")
rp.read()

if rp.can_fetch("*", "https://example.com/products/"):
    print("✓ Allowed to scrape /products/")
else:
    print("✗ Disallowed by robots.txt")

# Check the crawl delay
delay = rp.request_rate("*")
if delay:
    print(f"Recommended delay: {delay.requests} requests per {delay.seconds} seconds")

Choosing the right library: httpx with selectolax or BeautifulSoup

For static HTML pages, you don't need a heavyweight browser automation tool. Use httpx to fetch and selectolax or BeautifulSoup to parse. We reach for selectolax when speed matters; BeautifulSoup when we need stability and clarity.

httpx is a modern HTTP client that's async-friendly and handles cookies naturally:

python
import httpx
from selectolax.parser import HTMLParser

async def fetch_products(url: str) -> list[dict]:
    async with httpx.AsyncClient() as client:
        response = await client.get(url, timeout=10.0)
        response.raise_for_status()
    
    parser = HTMLParser(response.text)
    products = []
    
    for item in parser.css("div.product-card"):
        name = item.css_first("h2")
        price = item.css_first(".price")
        
        if name and price:
            products.append({
                "name": name.text(),
                "price": price.text(),
            })
    
    return products

If you prefer BeautifulSoup's syntax or need its error-handling, swap the parser:

python
from bs4 import BeautifulSoup
import httpx

def fetch_products(url: str) -> list[dict]:
    response = httpx.get(url, timeout=10.0)
    soup = BeautifulSoup(response.text, "html.parser")
    
    products = []
    for item in soup.select("div.product-card"):
        name_tag = item.select_one("h2")
        price_tag = item.select_one(".price")
        
        if name_tag and price_tag:
            products.append({
                "name": name_tag.get_text(strip=True),
                "price": price_tag.get_text(strip=True),
            })
    
    return products

The main trade-off: selectolax is faster (it uses the native Lexbor C parser), but BeautifulSoup is more forgiving with broken HTML and has deeper community documentation. For most projects, either works well. Choose based on your team's comfort and your speed requirements.

Handling JavaScript-heavy pages with Playwright

Some sites load content dynamically with JavaScript. A static HTTP request won't see it. That's when you need Playwright, a browser automation library. It's heavier than httpx but it renders the page like a real browser would.

Install it with:

python
pip install playwright
playwright install chromium

Here's a practical example scraping a page that loads products via JavaScript:

python
from playwright.async_api import async_playwright
from selectolax.parser import HTMLParser

async def scrape_js_products(url: str) -> list[dict]:
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        
        await page.goto(url, wait_until="networkidle")
        await page.wait_for_selector("div.product-card", timeout=5000)
        
        # Wait a bit longer if the page has animations
        await page.wait_for_timeout(2000)
        
        html = await page.content()
        await browser.close()
    
    parser = HTMLParser(html)
    products = []
    
    for item in parser.css("div.product-card"):
        name = item.css_first("h2")
        price = item.css_first(".price")
        
        if name and price:
            products.append({
                "name": name.text(),
                "price": price.text(),
            })
    
    return products

Key points on Playwright:

  • wait_until="networkidle" ensures the page is fully loaded before we extract content.
  • wait_for_selector blocks until the target element is in the DOM.
  • Always await browser.close() to avoid resource leaks.
  • Set reasonable timeouts; sites that take more than 10 seconds probably shouldn't be scraped.

Use Playwright only when necessary. It's resource-intensive and slow compared to static parsing. If the site has an API, use that. If the HTML is plain HTTP, use httpx. Reserve Playwright for truly dynamic content.

Rate-limiting and request caching

Hammering a server with rapid requests is how you get banned—and rightfully so. Implement rate-limiting and caching to scrape sustainably and reduce server load.

Here's a practical rate-limiter using asyncio and a simple cache with SQLite:

python
import asyncio
import hashlib
import sqlite3
from datetime import datetime, timedelta
import httpx

class ResponsibleScraper:
    def __init__(self, delay_seconds: float = 2.0, cache_ttl_hours: int = 24):
        self.delay_seconds = delay_seconds
        self.cache_ttl = timedelta(hours=cache_ttl_hours)
        self.last_request_time = 0
        self._init_cache()
    
    def _init_cache(self):
        self.conn = sqlite3.connect(":memory:")
        self.conn.execute("""
            CREATE TABLE cache (
                url_hash TEXT PRIMARY KEY,
                url TEXT,
                content TEXT,
                cached_at TIMESTAMP
            )
        """)
        self.conn.commit()
    
    def _get_cache_key(self, url: str) -> str:
        return hashlib.md5(url.encode()).hexdigest()
    
    def _get_cached(self, url: str) -> str | None:
        url_hash = self._get_cache_key(url)
        cursor = self.conn.execute(
            "SELECT content, cached_at FROM cache WHERE url_hash = ?",
            (url_hash,)
        )
        row = cursor.fetchone()
        
        if not row:
            return None
        
        content, cached_at = row
        cached_dt = datetime.fromisoformat(cached_at)
        
        if datetime.now() - cached_dt > self.cache_ttl:
            self.conn.execute("DELETE FROM cache WHERE url_hash = ?", (url_hash,))
            self.conn.commit()
            return None
        
        return content
    
    def _set_cached(self, url: str, content: str):
        url_hash = self._get_cache_key(url)
        self.conn.execute(
            """
            INSERT OR REPLACE INTO cache (url_hash, url, content, cached_at)
            VALUES (?, ?, ?, ?)
            """,
            (url_hash, url, content, datetime.now().isoformat())
        )
        self.conn.commit()
    
    async def fetch(self, url: str) -> str:
        # Check cache first
        cached = self._get_cached(url)
        if cached:
            print(f"Cache hit: {url}")
            return cached
        
        # Rate-limit: ensure minimum delay between requests
        elapsed = asyncio.get_event_loop().time() - self.last_request_time
        if elapsed < self.delay_seconds:
            await asyncio.sleep(self.delay_seconds - elapsed)
        
        print(f"Fetching: {url}")
        async with httpx.AsyncClient() as client:
            response = await client.get(url, timeout=10.0)
            response.raise_for_status()
        
        self.last_request_time = asyncio.get_event_loop().time()
        
        # Store in cache
        self._set_cached(url, response.text)
        
        return response.text

# Usage
async def main():
    scraper = ResponsibleScraper(delay_seconds=2.0, cache_ttl_hours=24)
    
    urls = [
        "https://example.com/page1",
        "https://example.com/page2",
        "https://example.com/page1",  # Will be cached
    ]
    
    for url in urls:
        html = await scraper.fetch(url)
        print(f"Fetched {len(html)} bytes from {url}")

# asyncio.run(main())

This scraper enforces a 2-second delay between requests and caches responses for 24 hours. The second request to page1 returns instantly from cache.

For distributed scraping across multiple machines, consider using a dedicated library like redis or RabbitMQ to coordinate rate-limits. A shared Redis instance can track request counts per domain and ensure your entire team respects the same delays.

Common mistakes

  • Ignoring robots.txt. It's not legally binding in most jurisdictions, but violating it is a signal you're acting in bad faith. Respect it.
  • Scraping too fast. Even if a site doesn't ban you, rapid requests consume their resources. Rate-limit generously—2–5 seconds is reasonable.
  • Not setting timeouts. If a request hangs, it'll block your scraper indefinitely. Always set timeout on HTTP calls.
  • Using headless browsers for static content. Playwright is slow. Use it only when JavaScript rendering is essential.
  • Parsing without error handling. Websites change. Your selectors will break. Use try-catch and log failures.
  • Not respecting the Crawl-delay directive. If robots.txt says wait 5 seconds, wait 5 seconds.
  • Scraping behind a login without permission. Even if you have credentials, scraping authenticated content often violates the TOS. Ask first.

Key takeaways

  • Check robots.txt and the site's terms of service before you start. Scraping responsibly means respecting the site owner's wishes.
  • Use httpx with selectolax or BeautifulSoup for static HTML; reserve Playwright for JavaScript-heavy pages.
  • Always implement rate-limiting and caching. Respect the Crawl-delay directive and cache responses to reduce server load.
  • Set reasonable timeouts on all network requests and handle parse failures gracefully.
  • When possible, ask for API access or a data partnership. Official feeds beat scraped HTML every time.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles