Chapter 20 of 21
Data Science: NumPy & Pandas
Current Study Module
Web Scraping: Beautiful Soup
High-Performance APIs: FastAPI
Chapter 19 • Data Ingestion & Automation

Web Scraping: BeautifulSoup4, Requests & Data Extraction

Web scraping is the automated extraction of semi-structured data from HTML documents into clean, structured formats like CSV, JSON, or SQL tables. In this chapter, you will master the HTTP request lifecycle, parse DOM hierarchies with BeautifulSoup4, optimize parser speeds withlxml, and implement resilient scraping pipelines with rate limiting and retry logic.

1. The HTTP Ingestion Pipeline: Requests & Headers

Before extracting HTML elements, your scraper must successfully download the raw HTML markup over HTTP. Web servers analyze incoming HTTP headers to identify clients. Default Python requests headers identify as python-requests/x.y.z, which many web application firewalls (WAFs) immediately block with a 403 Forbidden response.

Always provide realistic User-Agent and browser emulation headers, userequests.Session() to reuse underlying TCP connections (avoiding handshake overhead), and configure explicit network timeouts:

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# Configure an enterprise-grade resilient HTTP Session
def get_resilient_session() -> requests.Session:
    session = requests.Session()
    
    # 1. Realistic browser headers to avoid trivial bot blocking
    session.headers.update({
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) "
            "Chrome/124.0.0.0 Safari/537.36"
        ),
        "Accept-Language": "en-US,en;q=0.9",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    })
    
    # 2. Automated exponential backoff for transient server errors (500, 502, 503, 504)
    retries = Retry(
        total=4,
        backoff_factor=1.5,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["GET"]
    )
    adapter = HTTPAdapter(max_retries=retries)
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    
    return session

session = get_resilient_session()

try:
    # Always specify timeouts (connect_timeout, read_timeout)
    response = session.get("https://httpbin.org/headers", timeout=(3.05, 10))
    response.raise_for_status() # Raises HTTPError for 4xx or 5xx codes
    print("Request successful! Status code:", response.status_code)
except requests.exceptions.RequestException as err:
    print(f"Network error: {err}")

2. Parsing HTML with BeautifulSoup4

BeautifulSoup4 converts raw HTML string content into a navigable Python tree structure. Selecting the right parser backend determines speed and tolerance for broken markup:

ParserSpeedFault ToleranceExternal DependenciesBest Use Case
html.parserModerateGoodNone (Standard Library)Simple scripts, zero external dependencies
lxmlUltra-Fast (C-based)Very GoodRequires pip install lxmlHigh-throughput production scrapers
html5libSlowExtremely High (Browser-like)Requires pip install html5libSeverely broken or malformed legacy HTML

CSS Selectors vs Element Methods

BeautifulSoup provides two primary querying APIs: standard element search methods (.find(),.find_all()) and standard CSS selector queries (.select(), .select_one()). CSS selectors are generally more expressive and easier to adapt from browser DevTools:

from bs4 import BeautifulSoup

html_doc = """
<!DOCTYPE html>
<html>
<head><title>Online Bookstore Catalog</title></head>
<body>
  <div id="catalog" class="store-grid">
    <article class="product-card" data-id="book-101">
      <h3 class="title"><a href="/books/python-deep-dive">Python Deep Dive</a></h3>
      <div class="pricing">
        <span class="currency">$</span><span class="price">49.99</span>
      </div>
      <p class="stock instock">In Stock (14 copies left)</p>
    </article>
    <article class="product-card out-of-stock" data-id="book-102">
      <h3 class="title"><a href="/books/rust-internals">Rust Internals</a></h3>
      <div class="pricing">
        <span class="currency">$</span><span class="price">59.50</span>
      </div>
      <p class="stock">Out of Stock</p>
    </article>
  </div>
</body>
</html>
"""

# Parse with lxml (or fallback to html.parser)
soup = BeautifulSoup(html_doc, "html.parser")

# 1. Querying with CSS Selectors (.select returns list, .select_one returns first match)
products = soup.select("article.product-card")
print(f"Total products found: {len(products)}")

for card in products:
    # CSS selector chaining
    title_elem = card.select_one("h3.title a")
    price_elem = card.select_one("span.price")
    stock_elem = card.select_one("p.stock")
    
    # Extract text with whitespace stripping
    title = title_elem.get_text(strip=True) if title_elem else "N/A"
    price = float(price_elem.get_text(strip=True)) if price_elem else 0.0
    
    # Extract attributes safely using dictionary indexing or .get()
    relative_url = title_elem.get("href") if title_elem else ""
    data_id = card.get("data-id")
    
    # Check CSS classes
    classes = card.get("class", [])
    is_available = "instock" in (stock_elem.get("class", []) if stock_elem else [])
    
    print(f"[{data_id}] {title} | ${price} | Available: {is_available} | Link: {relative_url}")

3. Building an End-to-End Extraction Pipeline

Production scraping does not stop at printing text to the console. Extracted records must be validated, cleaned, and exported into structured data structures (such as Pandas DataFrames or JSON Lines):

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
import pandas as pd
import time
import random

def scrape_book_catalog(base_url: str, max_pages: int = 3) -> pd.DataFrame:
    session = requests.Session()
    session.headers.update({"User-Agent": "HiFiDataScraperBot/1.0 (+https://example.com/bot)"})
    
    records = []
    
    for page_num in range(1, max_pages + 1):
        target_url = f"{base_url}/catalogue/page-{page_num}.html"
        print(f"Scraping page {page_num}: {target_url}...")
        
        try:
            resp = session.get(target_url, timeout=10)
            if resp.status_code == 404:
                print("No more pages found.")
                break
            resp.raise_for_status()
        except requests.RequestException as e:
            print(f"Failed to fetch {target_url}: {e}")
            break
            
        soup = BeautifulSoup(resp.content, "html.parser")
        product_cards = soup.select("article.product_pod")
        
        for card in product_cards:
            title_node = card.select_one("h3 a")
            title = title_node.get("title", title_node.text.strip()) if title_node else "Unknown"
            
            # Clean currency string e.g. "£51.77" -> 51.77
            price_text = card.select_one("p.price_color")
            price = float(price_text.text.replace("£", "").replace("$", "")) if price_text else 0.0
            
            # Clean relative URL into absolute URL
            rel_link = title_node.get("href", "") if title_node else ""
            full_link = urljoin(target_url, rel_link)
            
            # Extract Star Rating from CSS class: class="star-rating Three"
            rating_node = card.select_one("p.star-rating")
            rating_classes = rating_node.get("class", []) if rating_node else []
            rating = [c for c in rating_classes if c != "star-rating"]
            rating_str = rating[0] if rating else "None"
            
            records.append({
                "title": title,
                "price_gbp": price,
                "rating": rating_str,
                "product_url": full_link
            })
            
        # Respectful scraping: Sleep with jitter between requests
        time.sleep(random.uniform(1.0, 2.5))
        
    df = pd.DataFrame(records)
    return df

# Example usage (sandbox test site designed for scraping):
# df = scrape_book_catalog("https://books.toscrape.com", max_pages=2)
# df.to_csv("books_dataset.csv", index=False)
# print(df.head())

4. Ethical Scraping & Handling Modern Countermeasures

The Golden Rules of Ethical Scraping

  • Check robots.txt: Parse https://target-site.com/robots.txt using Python's standard library urllib.robotparser.RobotFileParser before extracting data.
  • Throttle Request Frequency: Never flood a server with hundreds of simultaneous requests. Add randomized delays (jitter) between calls to prevent denial-of-service degradation.
  • Scrape During Off-Peak Hours: Schedule intensive data extraction during the website's local midnight hours to avoid degrading performance for genuine human visitors.
  • Prefer Official APIs: If the target platform exposes a documented REST/GraphQL API, always use the API instead of reverse-engineering fragile HTML markup.

When BeautifulSoup is Enough

HTML rendered entirely on the backend (Server-Side Rendering / SSR). The complete content is present inside the raw response.text payload. Fast, lightweight, and requires minimal RAM and CPU.

When Headless Browsers are Required

Modern Single-Page Applications (React, Next.js, Vue, Angular) where the server sends an empty<div id="root"></div> shell and loads data via client-side JavaScript. Use Playwright or Selenium to execute JavaScript in a real headless Chromium engine.

Explore Related Tools

Hand-picked utilities and calculators related to this tool.

Related Tools